diff --git a/pylsp/__main__.py b/pylsp/__main__.py index 760f8829..7871456a 100644 --- a/pylsp/__main__.py +++ b/pylsp/__main__.py @@ -4,7 +4,6 @@ import argparse import logging import logging.config -import sys import time try: @@ -12,13 +11,8 @@ except Exception: import json -from ._version import __version__ -from .python_lsp import ( - PythonLSPServer, - start_io_lang_server, - start_tcp_lang_server, - start_ws_lang_server, -) +from pylsp import __version__ +from pylsp.server import LSP_SERVER LOG_FORMAT = "%(asctime)s {} - %(levelname)s - %(name)s - %(message)s".format( time.localtime().tm_zone @@ -73,25 +67,18 @@ def main() -> None: args = parser.parse_args() _configure_logger(args.verbose, args.log_config, args.log_file) + if args.check_parent_process: + LSP_SERVER.check_parent_process() + if args.tcp: - start_tcp_lang_server( - args.host, args.port, args.check_parent_process, PythonLSPServer - ) + LSP_SERVER.start_tcp(args.host, args.port) elif args.ws: - start_ws_lang_server(args.port, args.check_parent_process, PythonLSPServer) + LSP_SERVER.start_ws( + args.host, + args.port, + ) else: - stdin, stdout = _binary_stdio() - start_io_lang_server(stdin, stdout, args.check_parent_process, PythonLSPServer) - - -def _binary_stdio(): - """Construct binary stdio streams (not text mode). - - This seems to be different for Window/Unix Python2/3, so going by: - https://stackoverflow.com/questions/2850893/reading-binary-data-from-stdin - """ - stdin, stdout = sys.stdin.buffer, sys.stdout.buffer - return stdin, stdout + LSP_SERVER.start_io() def _configure_logger(verbose=0, log_config=None, log_file=None) -> None: diff --git a/pylsp/_utils.py b/pylsp/_utils.py index dfe84b14..a29e382e 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -11,7 +11,7 @@ import sys import threading import time -from typing import Optional +from typing import Any, Iterable, List, Optional import docstring_to_markdown import jedi @@ -416,3 +416,10 @@ def get_eol_chars(text): if match: return match.group(0) return None + + +def flatten(lst: Iterable[Iterable[Any]]) -> List[Any] | None: + """Flatten a iterable of iterables into a single list.""" + if not lst: + return None + return [i for sublst in lst for i in sublst] diff --git a/pylsp/server/__init__.py b/pylsp/server/__init__.py new file mode 100644 index 00000000..37104f31 --- /dev/null +++ b/pylsp/server/__init__.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import typing as typ + +from lsprotocol import types as lsptyp +from pygls import server + +from pylsp import PYLSP, __version__ +from pylsp._utils import flatten, is_process_alive +from pylsp.server.protocol import LangageServerProtocol + +if typ.TYPE_CHECKING: + from pylsp.server.workspace import Workspace + +logger = logging.getLogger(__name__) + +MAX_WORKERS = 64 +PARENT_PROCESS_WATCH_INTERVAL = 10 # 10 s + +CONFIG_FILES = ("pycodestyle.cfg", "setup.cfg", "tox.ini", ".flake8") + + +class LanguageServer(server.LanguageServer): + """Python Language Server.""" + + lsp: LangageServerProtocol + + @property + def workspace(self) -> Workspace: + """Returns in-memory workspace.""" + return self.lsp.workspace + + def check_parent_process(self): + """Check if the parent process is still alive.""" + + async def watch_parent_process(): + ppid = os.getppid() + while True: + if self._stop_event is not None and self._stop_event.is_set(): + break + if not is_process_alive(ppid): + self.shutdown() + break + await asyncio.sleep(PARENT_PROCESS_WATCH_INTERVAL) + + asyncio.create_task(watch_parent_process()) + + +LSP_SERVER = LanguageServer( + name=PYLSP, + version=__version__, + max_workers=MAX_WORKERS, + protocol_cls=LangageServerProtocol, + text_document_sync_kind=lsptyp.TextDocumentSyncKind.Incremental, + notebook_document_sync=lsptyp.NotebookDocumentSyncOptions( + notebook_selector=[ + lsptyp.NotebookDocumentSyncOptionsNotebookSelectorType2( + cells=[ + lsptyp.NotebookDocumentSyncOptionsNotebookSelectorType2CellsType( + language="python" + ), + ], + ), + ], + ), +) + + +@LSP_SERVER.feature(lsptyp.INITIALIZE) +async def initialize(ls: LanguageServer, params: lsptyp.InitializeParams): + """Handle the initialization request.""" + # Call the initialization hook + await ls.lsp.call_hook("pylsp_initialize") + + +@LSP_SERVER.feature(lsptyp.INITIALIZED) +async def initialized(ls: LanguageServer, params: lsptyp.InitializedParams): + """Handle the initialized notification.""" + # Call the initialized hook + await ls.lsp.call_hook("pylsp_initialized") + + +@LSP_SERVER.feature(lsptyp.WORKSPACE_DID_CHANGE_CONFIGURATION) +async def workspace_did_change_configuration( + ls: LanguageServer, params: lsptyp.WorkspaceConfigurationParams +): + """Handle the workspace did change configuration notification.""" + for config_item in params.items: + ls.workspace.config.update({config_item.scope_uri: config_item.section}) + # TODO: Check configuration update is valid and supports this type of update + await ls.lsp.call_hook("pylsp_workspace_configuration_changed") + + +@LSP_SERVER.feature(lsptyp.WORKSPACE_DID_CHANGE_WATCHED_FILES) +def workspace_did_change_watched_files( + ls: LanguageServer, params: lsptyp.DidChangeWatchedFilesParams +): + """Handle the workspace did change watched files notification.""" + for change in params.changes: + if change.uri.endswith(CONFIG_FILES): + ls.workspace.config.settings.cache_clear() + break + + # TODO: check if necessary to link files not handled by textDocument/Open + + +@LSP_SERVER.feature(lsptyp.WORKSPACE_EXECUTE_COMMAND) +async def workspace_execute_command( + ls: LanguageServer, params: lsptyp.ExecuteCommandParams +): + """Handle the workspace execute command request.""" + # Call the execute command hook + await ls.lsp.call_hook( + "pylsp_execute_command", + command=params.command, + arguments=params.arguments, + work_done_token=params.work_done_token, + ) + + +@LSP_SERVER.feature(lsptyp.NOTEBOOK_DOCUMENT_DID_OPEN) +async def notebook_document_did_open( + ls: LanguageServer, params: lsptyp.DidOpenNotebookDocumentParams +): + """Handle the notebook document did open notification.""" + await ls.lsp.lint_notebook_document(params.notebook_document.uri) + + +@LSP_SERVER.feature(lsptyp.NOTEBOOK_DOCUMENT_DID_CHANGE) +async def notebook_document_did_change( + ls: LanguageServer, params: lsptyp.DidChangeNotebookDocumentParams +): + """Handle the notebook document did change notification.""" + await ls.lsp.lint_notebook_document(params.notebook_document.uri) + + +@LSP_SERVER.feature(lsptyp.NOTEBOOK_DOCUMENT_DID_SAVE) +async def notebook_document_did_save( + ls: LanguageServer, params: lsptyp.DidSaveNotebookDocumentParams +): + """Handle the notebook document did save notification.""" + await ls.lsp.lint_notebook_document(params.notebook_document.uri) + await ls.workspace.save(params.notebook_document.uri) + + +@LSP_SERVER.feature(lsptyp.NOTEBOOK_DOCUMENT_DID_CLOSE) +async def notebook_document_did_close( + ls: LanguageServer, params: lsptyp.DidCloseNotebookDocumentParams +): + """Handle the notebook document did close notification.""" + await ls.lsp.cancel_tasks(params.notebook_document.uri) + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DID_OPEN) +async def text_document_did_open( + ls: LanguageServer, params: lsptyp.DidOpenTextDocumentParams +): + """Handle the text document did open notification.""" + await ls.lsp.lint_text_document(params.text_document.uri) + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DID_CHANGE) +async def text_document_did_change( + ls: LanguageServer, params: lsptyp.DidChangeTextDocumentParams +): + """Handle the text document did change notification.""" + await ls.lsp.lint_text_document(params.text_document.uri) + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DID_SAVE) +async def text_document_did_save( + ls: LanguageServer, params: lsptyp.DidSaveTextDocumentParams +): + """Handle the text document did save notification.""" + await ls.lsp.lint_text_document(params.text_document.uri) + await ls.workspace.save(params.text_document.uri) + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DID_CLOSE) +async def text_document_did_close( + ls: LanguageServer, params: lsptyp.DidCloseTextDocumentParams +): + """Handle the text document did close notification.""" + await ls.lsp.cancel_tasks(params.text_document.uri) + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_CODE_ACTION) +async def text_document_code_action( + ls: LanguageServer, params: lsptyp.CodeActionParams +) -> typ.List[lsptyp.Command | lsptyp.CodeAction] | None: + """Handle the text document code action request.""" + actions: typ.List[lsptyp.Command | lsptyp.CodeAction] | None = flatten( + await ls.lsp.call_hook( + "pylsp_code_action", + params.text_document.uri, + range=params.range, + context=params.context, + work_done_token=params.work_done_token, + ) + ) + return actions + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_CODE_LENS) +async def text_document_code_lens( + ls: LanguageServer, params: lsptyp.CodeLensParams +) -> typ.List[lsptyp.CodeLens] | None: + """Handle the text document code lens request.""" + lenses: typ.List[lsptyp.CodeLens] | None = flatten( + await ls.lsp.call_hook( + "pylsp_code_lens", + params.text_document.uri, + work_done_token=params.work_done_token, + ) + ) + return lenses + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_COMPLETION) +async def text_document_completion( + ls: LanguageServer, params: lsptyp.CompletionParams +) -> typ.List[lsptyp.CompletionItem] | None: + """Handle the text document completion request.""" + completions: typ.List[lsptyp.CompletionItem] | None = flatten( + await ls.lsp.call_hook( + "pylsp_completion", + params.text_document.uri, + position=params.position, + context=params.context, + work_done_token=params.work_done_token, + ) + ) + return completions + + +@LSP_SERVER.feature(lsptyp.COMPLETION_ITEM_RESOLVE) +async def completion_item_resolve( + ls: LanguageServer, params: lsptyp.CompletionItem +) -> lsptyp.CompletionItem | None: + """Handle the completion item resolve request.""" + item: lsptyp.CompletionItem | None = await ls.lsp.call_hook( + "pylsp_completion_item_resolve", + (params.data or {}).get("doc_uri"), + completion_item=params, + ) + return item + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DEFINITION) +async def text_document_definition( + ls: LanguageServer, params: lsptyp.DefinitionParams +) -> lsptyp.Location | None: + """Handle the text document definition request.""" + location: lsptyp.Location | None = await ls.lsp.call_hook( + "pylsp_definitions", + params.text_document.uri, + position=params.position, + work_done_token=params.work_done_token, + ) + return location + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_TYPE_DEFINITION) +async def text_document_type_definition( + ls: LanguageServer, params: lsptyp.TypeDefinitionParams +) -> lsptyp.Location | None: + """Handle the text document type definition request.""" + location: lsptyp.Location | None = await ls.lsp.call_hook( + "pylsp_type_definition", + params.text_document.uri, + position=params.position, + work_done_token=params.work_done_token, + ) + return location + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DOCUMENT_HIGHLIGHT) +async def text_document_document_highlight( + ls: LanguageServer, params: lsptyp.DocumentHighlightParams +) -> typ.List[lsptyp.DocumentHighlight] | None: + """Handle the text document document highlight request.""" + highlights: typ.List[lsptyp.DocumentHighlight] | None = flatten( + await ls.lsp.call_hook( + "pylsp_document_highlight", + params.text_document.uri, + position=params.position, + work_done_token=params.work_done_token, + ) + ) + return highlights + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_HOVER) +async def text_document_hover( + ls: LanguageServer, params: lsptyp.HoverParams +) -> lsptyp.Hover | None: + """Handle the text document hover request.""" + hover: lsptyp.Hover = await ls.lsp.call_hook( + "pylsp_hover", + params.text_document.uri, + position=params.position, + work_done_token=params.work_done_token, + ) + return hover + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) +async def text_document_document_symbol( + ls: LanguageServer, params: lsptyp.DocumentSymbolParams +) -> typ.List[lsptyp.DocumentSymbol] | None: + """Handle the text document document symbol request.""" + symbols: typ.List[lsptyp.DocumentSymbol] | None = flatten( + await ls.lsp.call_hook( + "pylsp_document_symbols", + params.text_document.uri, + work_done_token=params.work_done_token, + ) + ) + return symbols + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_FORMATTING) +async def text_document_formatting( + ls: LanguageServer, params: lsptyp.DocumentFormattingParams +) -> typ.List[lsptyp.TextEdit] | None: + """Handle the text document formatting request.""" + edits: typ.List[lsptyp.TextEdit] | None = flatten( + await ls.lsp.call_hook( + "pylsp_format_document", + params.text_document.uri, + options=params.options, + ) + ) + return edits + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_RANGE_FORMATTING) +async def text_document_range_formatting( + ls: LanguageServer, params: lsptyp.DocumentRangeFormattingParams +) -> typ.List[lsptyp.TextEdit] | None: + """Handle the text document range formatting request.""" + edits: typ.List[lsptyp.TextEdit] | None = flatten( + await ls.lsp.call_hook( + "pylsp_format_range", + params.text_document.uri, + range=params.range, + options=params.options, + ) + ) + return edits + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_FOLDING_RANGE) +async def text_document_folding_range( + ls: LanguageServer, params: lsptyp.FoldingRangeParams +) -> typ.List[lsptyp.FoldingRange] | None: + """Handle the text document folding range request.""" + ranges: typ.List[lsptyp.FoldingRange] | None = flatten( + await ls.lsp.call_hook( + "pylsp_folding_range", + params.text_document.uri, + work_done_token=params.work_done_token, + ) + ) + return ranges + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_REFERENCES) +async def text_document_references( + ls: LanguageServer, params: lsptyp.ReferenceParams +) -> typ.List[lsptyp.Location] | None: + """Handle the text document references request.""" + locations: typ.List[lsptyp.Location] | None = flatten( + await ls.lsp.call_hook( + "pylsp_references", + params.text_document.uri, + position=params.position, + context=params.context, + ) + ) + return locations + + +@LSP_SERVER.feature(lsptyp.TEXT_DOCUMENT_SIGNATURE_HELP) +async def text_document_signature_help( + ls: LanguageServer, params: lsptyp.SignatureHelpParams +) -> lsptyp.SignatureHelp | None: + """Handle the text document signature help request.""" + signature_help: lsptyp.SignatureHelp | None = await ls.lsp.call_hook( + "pylsp_signature_help", + params.text_document.uri, + position=params.position, + work_done_token=params.work_done_token, + ) + return signature_help diff --git a/pylsp/server/protocol.py b/pylsp/server/protocol.py new file mode 100644 index 00000000..468e4d50 --- /dev/null +++ b/pylsp/server/protocol.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +import logging +import typing as typ +from functools import partial + +from lsprotocol import types as typlsp +from lsprotocol.types import ( + INITIALIZE, + InitializeParams, + InitializeResult, + TraceValues, +) +from pygls import protocol +from pygls.capabilities import ServerCapabilitiesBuilder +from pygls.uris import from_fs_path + +from pylsp import PYLSP, hookspecs +from pylsp.config.config import PluginManager +from pylsp.server.workspace import Workspace + +logger = logging.getLogger(__name__) + + +class LangageServerProtocol(protocol.LanguageServerProtocol): + """Custom features implementation for the Python Language Server.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._pm = PluginManager(PYLSP) + if logger.level <= logging.DEBUG: + self._pm.trace.root.setwriter(logger.debug) + self._pm.enable_tracing() + self._pm.add_hookspecs(hookspecs) + + @property + def plugin_manager(self) -> PluginManager: + """Returns the plugin manager.""" + return self._pm + + @property + def workspace(self) -> Workspace: + if self._workspace is None: + raise RuntimeError( + "The workspace is not available - has the server been initialized?" + ) + + return typ.cast(Workspace, self._workspace) + + @protocol.lsp_method(INITIALIZE) + def lsp_initialize(self, params: InitializeParams) -> InitializeResult: + """Method that initializes language server. + It will compute and return server capabilities based on + registered features. + """ + logger.info("Language server initialized %s", params) + + self._server.process_id = params.process_id + self.initialization_options = params.initialization_options or {} + + text_document_sync_kind = self._server._text_document_sync_kind + notebook_document_sync = self._server._notebook_document_sync + + # Initialize server capabilities + self.client_capabilities = params.capabilities + self.server_capabilities = ServerCapabilitiesBuilder( + self.client_capabilities, + set({**self.fm.features, **self.fm.builtin_features}.keys()), + self.fm.feature_options, + list(self.fm.commands.keys()), + text_document_sync_kind, + notebook_document_sync, + ).build() + logger.debug( + "Server capabilities: %s", + json.dumps(self.server_capabilities, default=self._serialize_message), + ) + + root_path = params.root_path + root_uri = params.root_uri + if root_path is not None and root_uri is None: + root_uri = from_fs_path(root_path) + + # Initialize the workspace + workspace_folders = params.workspace_folders or [] + self._workspace = Workspace( + self._server, + root_uri, + text_document_sync_kind, + workspace_folders, + self.server_capabilities.position_encoding, + ) + + self.trace = TraceValues.Off + + return InitializeResult( + capabilities=self.server_capabilities, + server_info=self.server_info, + ) + + async def call_hook( + self, + hook_name: str, + doc_uri: str | None = None, + work_done_token: typlsp.ProgressToken | None = None, + **kwargs, + ): + """Calls hook_name and returns a list of results from all registered handlers. + + Args: + hook_name (str): The name of the hook to call. + doc_uri (str | None): The document URI to pass to the hook. + work_done_token (ProgressToken | None): The progress token to use for + reporting progress. + **kwargs: Additional keyword arguments to pass to the hook. + """ + if doc_uri: + doc = self.workspace.get_text_document(doc_uri) + else: + doc = None + + workspace_folder = ( + self.workspace.get_document_folder(doc_uri) if doc_uri else None + ) + + folder_uri = ( + workspace_folder.uri if workspace_folder else self.workspace._root_uri + ) + + hook_handlers_caller = self.plugin_manager.subset_hook_caller( + hook_name, self.workspace.config.disabled_plugins + ) + + if work_done_token is not None: + await self.progress.create_async(work_done_token) + + return await self._server.loop.run_in_executor( + self._server.thread_pool_executor, + partial( + hook_handlers_caller, + lsp=self, + workspace=folder_uri, + document=doc, + **kwargs, + ), + ) diff --git a/pylsp/server/workspace.py b/pylsp/server/workspace.py new file mode 100644 index 00000000..9b988145 --- /dev/null +++ b/pylsp/server/workspace.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import typing as typ +from pathlib import Path + +from lsprotocol.types import WorkspaceFolder +from pygls import uris, workspace +from pygls.workspace.text_document import TextDocument + +from pylsp.config.config import Config + +if typ.TYPE_CHECKING: + from pygls.server import LanguageServer + + +class Workspace(workspace.Workspace): + """Custom Workspace class for pylsp.""" + + def __init__(self, server: LanguageServer, *args, **kwargs): + self._server = server + super().__init__(*args, **kwargs) + self._config = Config( + self._root_uri, + self._server.lsp.initialization_options, + self._server.process_id, + self._server.server_capabilities, + ) + + @property + def config(self) -> Config: + return self._config + + def get_document_folder(self, doc_uri: str) -> WorkspaceFolder | None: + """Get the workspace folder for a given document URI. + + Finds the folder that is the longest prefix of the document URI. + + Args: + doc_uri (str): The document URI. + + Returns: + WorkspaceFolder | None: The workspace folder containing the document, or + None if not found. + """ + best_match_len = float("inf") + best_match = None + document_path = Path(uris.to_fs_path(doc_uri) or "") + for folder_uri, folder in self._folders.items(): + folder_path = Path(uris.to_fs_path(folder_uri) or "") + if ( + match_len := len(document_path.relative_to(folder_path).parts) + < best_match_len + ): + best_match_len = match_len + best_match = folder + + return best_match + + def _create_text_document( + self, + doc_uri: str, + source: str | None = None, + version: int | None = None, + language_id: str | None = None, + ) -> Document: + return Document( + self, + doc_uri, + source=source, + version=version, + language_id=language_id, + sync_kind=self._sync_kind, + position_codec=self._position_codec, + ) + + +class Document(TextDocument): + def __init__(self, workspace: Workspace, *args, **kwargs): + self._workspace = workspace + super().__init__(*args, **kwargs) + + @property + def workspace(self) -> Workspace: + return self._workspace + + @property + def workspace_folder(self) -> WorkspaceFolder | None: + return self._workspace.get_document_folder(self.uri)