From 2f607ff2b0137827904ad93dbc5b5c4a7a743773 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 11:39:30 +0200 Subject: [PATCH 01/30] Add package solidlsp with copies of classes from multilspy (unmodified) --- pyproject.toml | 8 +- src/solidlsp/ls.py | 1851 +++++++++++++++++++++++++++++++++++ src/solidlsp/ls_handler.py | 531 ++++++++++ src/solidlsp/lsp_request.py | 435 ++++++++ 4 files changed, 2821 insertions(+), 4 deletions(-) create mode 100644 src/solidlsp/ls.py create mode 100644 src/solidlsp/ls_handler.py create mode 100644 src/solidlsp/lsp_request.py diff --git a/pyproject.toml b/pyproject.toml index 467632ab..c7bc40a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,10 +112,10 @@ PYDEVD_DISABLE_FILE_VALIDATION = "1" # For custom markers, one can either adjust the env var or just use -m option in the command line, # as the second -m option will override the first one. test = "pytest test -vv -m \"${PYTEST_MARKERS:-not java and not rust and not isolated_process}\"" -_black_check = "black --check --exclude src/multilspy/ src scripts test" -_ruff_check = "ruff check --exclude .venv/ --exclude src/multilspy/ src scripts test" -_black_format = "black --exclude .venv/|src/multilspy/ src scripts test" -_ruff_format = "ruff check --exclude src/multilspy/ --fix src scripts test" +_black_check = "black --check --exclude src/multilspy/ --exclude src/solidlsp/ src scripts test" +_ruff_check = "ruff check --exclude .venv/ --exclude src/multilspy/ --exclude src/solidlsp/ src scripts test" +_black_format = "black --exclude .venv/|src/multilspy/|src/solidlsp/ src scripts test" +_ruff_format = "ruff check --exclude src/multilspy/ --exclude src/solidlsp/ --fix src scripts test" lint = [ "_black_check", "_ruff_check", diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py new file mode 100644 index 00000000..6d84cad9 --- /dev/null +++ b/src/solidlsp/ls.py @@ -0,0 +1,1851 @@ +import asyncio +import json +import logging +import os +import pathlib +import pickle +import re +import threading +from collections import defaultdict +from contextlib import contextmanager +from copy import copy +from pathlib import Path, PurePath +from typing import Dict, Iterator, List, Optional, Tuple, Union, cast, Self + +import pathspec +import tqdm +from overrides import override + +from multilspy import multilspy_types +from multilspy.language_server import LSPFileBuffer, GenericDocumentSymbol, ReferenceInSymbol +from multilspy.lsp_protocol_handler import lsp_types +from multilspy.lsp_protocol_handler import lsp_types as LSPTypes +from multilspy.lsp_protocol_handler.lsp_constants import LSPConstants +from multilspy.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink, SymbolKind, InitializeParams +from multilspy.lsp_protocol_handler.server import ( + Error, + LanguageServerHandler, + ProcessLaunchInfo, + StringDict, +) +from multilspy.multilspy_config import Language, MultilspyConfig +from multilspy.multilspy_exceptions import MultilspyException +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.multilspy_utils import FileUtils, PathUtils, TextUtils +from serena.text_utils import MatchedConsecutiveLines, search_files + + +class LanguageServer: + """ + The LanguageServer class provides a language agnostic interface to the Language Server Protocol. + It is used to communicate with Language Servers of different programming languages. + """ + + # To be overridden and extended by subclasses + def is_ignored_dirname(self, dirname: str) -> bool: + """ + A language-specific condition for directories that should always be ignored. For example, venv + in Python and node_modules in JS/TS should be ignored always. + """ + return dirname.startswith('.') + + @classmethod + def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str) -> "LanguageServer": + """ + Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. + + If language is Java, then ensure that jdk-17.0.6 or higher is installed, `java` is in PATH, and JAVA_HOME is set to the installation directory. + If language is JS/TS, then ensure that node (v18.16.0 or higher) is installed and in PATH. + + :param repository_root_path: The root path of the repository. + :param config: The Multilspy configuration. + :param logger: The logger to use. + :return LanguageServer: A language specific LanguageServer instance. + """ + if config.code_language == Language.PYTHON: + from multilspy.language_servers.pyright_language_server.pyright_server import ( + PyrightServer, + ) + + return PyrightServer(config, logger, repository_root_path) + # It used to be jedi, but pyright is a bit faster, and also more actively maintained + # Keeping the previous code for reference + # from multilspy.language_servers.jedi_language_server.jedi_server import ( + # JediServer, + # ) + + # return JediServer(config, logger, repository_root_path) + elif config.code_language == Language.JAVA: + from multilspy.language_servers.eclipse_jdtls.eclipse_jdtls import ( + EclipseJDTLS, + ) + + return EclipseJDTLS(config, logger, repository_root_path) + elif config.code_language == Language.KOTLIN: + from multilspy.language_servers.kotlin_language_server.kotlin_language_server import ( + KotlinLanguageServer, + ) + + return KotlinLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.RUST: + from multilspy.language_servers.rust_analyzer.rust_analyzer import ( + RustAnalyzer, + ) + + return RustAnalyzer(config, logger, repository_root_path) + elif config.code_language == Language.CSHARP: + from multilspy.language_servers.omnisharp.omnisharp import OmniSharp + + return OmniSharp(config, logger, repository_root_path) + elif config.code_language in [Language.TYPESCRIPT, Language.JAVASCRIPT]: + from multilspy.language_servers.typescript_language_server.typescript_language_server import ( + TypeScriptLanguageServer, + ) + return TypeScriptLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.GO: + from multilspy.language_servers.gopls.gopls import Gopls + + return Gopls(config, logger, repository_root_path) + elif config.code_language == Language.RUBY: + from multilspy.language_servers.solargraph.solargraph import Solargraph + + return Solargraph(config, logger, repository_root_path) + elif config.code_language == Language.DART: + from multilspy.language_servers.dart_language_server.dart_language_server import DartLanguageServer + + return DartLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.CPP: + from multilspy.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer + + return ClangdLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.PHP: + from multilspy.language_servers.intelephense.intelephense import Intelephense + + return Intelephense(config, logger, repository_root_path) + else: + logger.log(f"Language {config.code_language} is not supported", logging.ERROR) + raise MultilspyException(f"Language {config.code_language} is not supported") + + def __init__( + self, + config: MultilspyConfig, + logger: MultilspyLogger, + repository_root_path: str, + process_launch_info: ProcessLaunchInfo, + language_id: str, + ): + """ + Initializes a LanguageServer instance. + + Do not instantiate this class directly. Use `LanguageServer.create` method instead. + + :param config: The Multilspy configuration. + :param logger: The logger to use. + :param repository_root_path: The root path of the repository. + :param process_launch_info: Each language server has a specific command used to start the server. + This parameter is the command to launch the language server process. + The command must pass appropriate flags to the binary, so that it runs in the stdio mode, + as opposed to HTTP, TCP modes supported by some language servers. + """ + if type(self) == LanguageServer: + raise MultilspyException( + "LanguageServer is an abstract class and cannot be instantiated directly. Use LanguageServer.create method instead." + ) + + self.logger = logger + self.repository_root_path: str = repository_root_path + self.logger.log(f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}", logging.DEBUG) + + self.language_id = language_id + self.open_file_buffers: Dict[str, LSPFileBuffer] = {} + self.language = Language(language_id) + + # load cache first to prevent any racing conditions due to asyncio stuff + self._document_symbols_cache: dict[str, Tuple[str, Tuple[List[multilspy_types.UnifiedSymbolInformation], List[multilspy_types.UnifiedSymbolInformation]]]] = {} + """Maps file paths to a tuple of (file_content_hash, result_of_request_document_symbols)""" + self._cache_lock = threading.Lock() + self._cache_has_changed: bool = False + self.load_cache() + + self.server_started = False + self.completions_available = asyncio.Event() + if config.trace_lsp_communication: + def logging_fn(source: str, target: str, msg: StringDict | str): + self.logger.log(f"LSP: {source} -> {target}: {str(msg)[:90]}...", self.logger.logger.level) + else: + logging_fn = None + + + # cmd is obtained from the child classes, which provide the language specific command to start the language server + # LanguageServerHandler provides the functionality to start the language server and communicate with it + self.logger.log(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}", logging.DEBUG) + self.server = LanguageServerHandler( + process_launch_info, + logger=logging_fn, + start_independent_lsp_process=config.start_independent_lsp_process, + ) + + + # Set up the pathspec matcher for the ignored paths + # for all absolute paths in ignored_paths, convert them to relative paths + processed_patterns = [] + for pattern in set(config.ignored_paths): + # Normalize separators (pathspec expects forward slashes) + pattern = pattern.replace(os.path.sep, '/') + processed_patterns.append(pattern) + self.logger.log(f"Processing {len(processed_patterns)} ignored paths from the config", logging.DEBUG) + + # Create a pathspec matcher from the processed patterns + self._ignore_spec = pathspec.PathSpec.from_lines( + pathspec.patterns.GitWildMatchPattern, + processed_patterns + ) + + def get_ignore_spec(self) -> pathspec.PathSpec: + """Returns the pathspec matcher for the paths that were configured to be ignored through + the multilspy config. + + This is is a subset of the full language-specific ignore spec that determines + which files are relevant for the language server. + + This matcher is useful for operations outside of the language server, + such as when searching for relevant non-language files in the project. + """ + return self._ignore_spec + + def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: + """ + Determine if a path should be ignored based on file type + and ignore patterns. + + :param relative_path: Relative path to check + :param ignore_unsupported_files: whether files that are not supported source files should be ignored + + :return: True if the path should be ignored, False otherwise + """ + abs_path = os.path.join(self.repository_root_path, relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError(f"File {abs_path} not found, the ignore check cannot be performed") + + # Check file extension if it's a file + is_file = os.path.isfile(abs_path) + if is_file and ignore_unsupported_files: + fn_matcher = self.language.get_source_fn_matcher() + if not fn_matcher.is_relevant_filename(abs_path): + return True + + # Create normalized path for consistent handling + rel_path = Path(relative_path) + + # Check each part of the path against always fulfilled ignore conditions + dir_parts = rel_path.parts + if is_file: + dir_parts = dir_parts[:-1] + for part in dir_parts: + if not part: # Skip empty parts (e.g., from leading '/') + continue + if self.is_ignored_dirname(part): + return True + + # Use pathspec for gitignore-style pattern matching + # Normalize path separators for pathspec (it expects forward slashes) + normalized_path = str(rel_path).replace(os.path.sep, '/') + + # pathspec can't handle the matching of directories if they don't end with a slash! + # see https://github.com/cpburnz/python-pathspec/issues/89 + if os.path.isdir(os.path.join(self.repository_root_path, normalized_path)) and not normalized_path.endswith('/'): + normalized_path = normalized_path + '/' + + # Use the pathspec matcher to check if the path matches any ignore pattern + if self.get_ignore_spec().match_file(normalized_path): + return True + + return False + + async def _shutdown(self, timeout: float = 5.0): + """ + A robust shutdown process designed to terminate cleanly on all platforms, including Windows, + by explicitly closing all I/O pipes. + """ + if not self.server.is_running(): + self.logger.log("Server process not running, skipping shutdown.", logging.DEBUG) + return + + self.logger.log(f"Initiating final robust shutdown with a {timeout}s timeout...", logging.INFO) + process = self.server.process + reader_tasks = list(self.server.tasks.values()) + + # --- Main Shutdown Logic --- + try: + # Stage 1: Graceful Termination Request + # Send LSP shutdown and close stdin to signal no more input. + try: + await asyncio.wait_for(self.server.shutdown(), timeout=2.0) + if process.stdin and not process.stdin.is_closing(): + process.stdin.close() + except Exception: + pass # Ignore errors here, we are proceeding to terminate anyway. + + # Stage 2: Terminate and Concurrently Drain stdout/stderr + process.terminate() + + # Wait for the process to exit AND for the output pipes to be drained. + # The reader tasks will exit when they hit EOF. + await asyncio.wait_for( + asyncio.gather(process.wait(), *reader_tasks), + timeout=timeout - 2.0 + ) + self.logger.log("Process terminated and output pipes drained.", logging.INFO) + + except asyncio.TimeoutError: + # Stage 3: Forceful Kill + self.logger.log("Graceful termination failed. Forcefully killing process...", logging.WARNING) + if self.server.is_running(): + try: + process.kill() + # Wait for the killed process to be reaped by the OS. + await process.wait() + except Exception as e: + self.logger.log(f"Error during forceful kill: {e}", logging.ERROR) + + except Exception as e: + self.logger.log(f"An unexpected error occurred during shutdown logic: {e}", logging.ERROR) + + finally: + # === STAGE 4: EXPLICIT TASK & PIPE CLEANUP === + self.logger.log("Performing final task cancellation and pipe handle cleanup...", logging.DEBUG) + + # 1. Cancel any lingering reader tasks. + for task in reader_tasks: + if not task.done(): + task.cancel() + # Wait for cancellations to complete. + await asyncio.gather(*reader_tasks, return_exceptions=True) + self.server.tasks = {} + + # 2. Explicitly close each pipe to release OS handles. + for pipe in [process.stdin, process.stdout, process.stderr]: + if pipe and hasattr(pipe, 'is_closing') and not pipe.is_closing(): + try: + pipe.close() + except Exception: + pass + + # 3. Null out the process object in the handler. + self.server.process = None + self.logger.log("Shutdown sequence fully finished.", logging.DEBUG) + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["LanguageServer"]: + """ + Starts the Language Server and yields the LanguageServer instance. + """ + self.server_started = True + try: + yield self + finally: + self.server_started = False + await self._shutdown() + + @contextmanager + def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]: + """ + Open a file in the Language Server. This is required before making any requests to the Language Server. + + :param relative_file_path: The relative path of the file to open. + """ + if not self.server_started: + self.logger.log( + "open_file called before Language Server started", + logging.ERROR, + ) + raise MultilspyException("Language Server not started") + + absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) + uri = pathlib.Path(absolute_file_path).as_uri() + + if uri in self.open_file_buffers: + assert self.open_file_buffers[uri].uri == uri + assert self.open_file_buffers[uri].ref_count >= 1 + + self.open_file_buffers[uri].ref_count += 1 + yield self.open_file_buffers[uri] + self.open_file_buffers[uri].ref_count -= 1 + else: + contents = FileUtils.read_file(self.logger, absolute_file_path) + + version = 0 + self.open_file_buffers[uri] = LSPFileBuffer(uri, contents, version, self.language_id, 1) + + self.server.notify.did_open_text_document( + { + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: uri, + LSPConstants.LANGUAGE_ID: self.language_id, + LSPConstants.VERSION: 0, + LSPConstants.TEXT: contents, + } + } + ) + yield self.open_file_buffers[uri] + self.open_file_buffers[uri].ref_count -= 1 + + if self.open_file_buffers[uri].ref_count == 0: + self.server.notify.did_close_text_document( + { + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: uri, + } + } + ) + del self.open_file_buffers[uri] + + def insert_text_at_position( + self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str + ) -> multilspy_types.Position: + """ + Insert text at the given line and column in the given file and return + the updated cursor position after inserting the text. + + :param relative_file_path: The relative path of the file to open. + :param line: The line number at which text should be inserted. + :param column: The column number at which text should be inserted. + :param text_to_be_inserted: The text to insert. + """ + if not self.server_started: + self.logger.log( + "insert_text_at_position called before Language Server started", + logging.ERROR, + ) + raise MultilspyException("Language Server not started") + + absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) + uri = pathlib.Path(absolute_file_path).as_uri() + + # Ensure the file is open + assert uri in self.open_file_buffers + + file_buffer = self.open_file_buffers[uri] + file_buffer.version += 1 + + new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted) + file_buffer.contents = new_contents + self.server.notify.did_change_text_document( + { + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.VERSION: file_buffer.version, + LSPConstants.URI: file_buffer.uri, + }, + LSPConstants.CONTENT_CHANGES: [ + { + LSPConstants.RANGE: { + "start": {"line": line, "character": column}, + "end": {"line": line, "character": column}, + }, + "text": text_to_be_inserted, + } + ], + } + ) + return multilspy_types.Position(line=new_l, character=new_c) + + def delete_text_between_positions( + self, + relative_file_path: str, + start: multilspy_types.Position, + end: multilspy_types.Position, + ) -> str: + """ + Delete text between the given start and end positions in the given file and return the deleted text. + """ + if not self.server_started: + self.logger.log( + "insert_text_at_position called before Language Server started", + logging.ERROR, + ) + raise MultilspyException("Language Server not started") + + absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) + uri = pathlib.Path(absolute_file_path).as_uri() + + # Ensure the file is open + assert uri in self.open_file_buffers + + file_buffer = self.open_file_buffers[uri] + file_buffer.version += 1 + new_contents, deleted_text = TextUtils.delete_text_between_positions(file_buffer.contents, start_line=start["line"], start_col=start["character"], end_line=end["line"], end_col=end["character"]) + file_buffer.contents = new_contents + self.server.notify.did_change_text_document( + { + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.VERSION: file_buffer.version, + LSPConstants.URI: file_buffer.uri, + }, + LSPConstants.CONTENT_CHANGES: [{LSPConstants.RANGE: {"start": start, "end": end}, "text": ""}], + } + ) + return deleted_text + + async def _send_definition_request(self, definition_params: DefinitionParams) -> Union[Definition, List[LocationLink], None]: + return await self.server.send.definition(definition_params) + + async def request_definition( + self, relative_file_path: str, line: int, column: int + ) -> List[multilspy_types.Location]: + """ + Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server + for the symbol at the given line and column in the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + + :return List[multilspy_types.Location]: A list of locations where the symbol is defined + """ + + if not self.server_started: + self.logger.log( + "find_function_definition called before Language Server started", + logging.ERROR, + ) + raise MultilspyException("Language Server not started") + + with self.open_file(relative_file_path): + # sending request to the language server and waiting for response + definition_params = cast(DefinitionParams, { + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: pathlib.Path( + str(PurePath(self.repository_root_path, relative_file_path)) + ).as_uri() + }, + LSPConstants.POSITION: { + LSPConstants.LINE: line, + LSPConstants.CHARACTER: column, + }, + }) + response = await self._send_definition_request(definition_params) + + ret: List[multilspy_types.Location] = [] + if isinstance(response, list): + # response is either of type Location[] or LocationLink[] + for item in response: + assert isinstance(item, dict) + if LSPConstants.URI in item and LSPConstants.RANGE in item: + new_item: multilspy_types.Location = {} + new_item.update(item) + new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) + new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) + ret.append(multilspy_types.Location(new_item)) + elif ( + LSPConstants.ORIGIN_SELECTION_RANGE in item + and LSPConstants.TARGET_URI in item + and LSPConstants.TARGET_RANGE in item + and LSPConstants.TARGET_SELECTION_RANGE in item + ): + new_item: multilspy_types.Location = {} + new_item["uri"] = item[LSPConstants.TARGET_URI] + new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) + new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) + new_item["range"] = item[LSPConstants.TARGET_SELECTION_RANGE] + ret.append(multilspy_types.Location(**new_item)) + else: + assert False, f"Unexpected response from Language Server: {item}" + elif isinstance(response, dict): + # response is of type Location + assert LSPConstants.URI in response + assert LSPConstants.RANGE in response + + new_item: multilspy_types.Location = {} + new_item.update(response) + new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) + new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) + ret.append(multilspy_types.Location(**new_item)) + elif response is None: + # Some language servers return None when they cannot find a definition + # This is expected for certain symbol types like generics or types with incomplete information + self.logger.log( + f"Language server returned None for definition request at {relative_file_path}:{line}:{column}", + logging.WARNING, + ) + else: + assert False, f"Unexpected response from Language Server: {response}" + + return ret + + # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses + async def _send_references_request(self, relative_file_path: str, line: int, column: int) -> List[lsp_types.Location] | None: + return await self.server.send.references( + { + "textDocument": {"uri": PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": False}, + } + ) + + async def request_references( + self, relative_file_path: str, line: int, column: int + ) -> List[multilspy_types.Location]: + """ + Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server + to find references to the symbol at the given line and column in the given file. Wait for the response and return the result. + Filters out references located in ignored directories. + + :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + + :return: A list of locations where the symbol is referenced (excluding ignored directories) + """ + + if not self.server_started: + self.logger.log( + "request_references called before Language Server started", + logging.ERROR, + ) + raise MultilspyException("Language Server not started") + + + with self.open_file(relative_file_path): + try: + response = await self._send_references_request(relative_file_path, line=line, column=column) + except Exception as e: + # Catch LSP internal error (-32603) and raise a more informative exception + if isinstance(e, Error) and getattr(e, 'code', None) == -32603: + raise RuntimeError( + f"LSP internal error (-32603) when requesting references for {relative_file_path}:{line}:{column}. " + "This often occurs when requesting references for a symbol not referenced in the expected way. " + ) from e + raise + if response is None: + return [] + + ret: List[multilspy_types.Location] = [] + assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" + for item in response: + assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" + assert LSPConstants.URI in item + assert LSPConstants.RANGE in item + + abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) + rel_path = Path(abs_path).relative_to(self.repository_root_path) + if self.is_ignored_path(str(rel_path)): + self.logger.log(f"Ignoring reference in {rel_path} since it should be ignored", logging.DEBUG) + continue + + new_item: multilspy_types.Location = {} + new_item.update(item) + new_item["absolutePath"] = str(abs_path) + new_item["relativePath"] = str(rel_path) + ret.append(multilspy_types.Location(**new_item)) + + return ret + + async def request_references_with_content( + self, relative_file_path: str, line: int, column: int, context_lines_before: int = 0, context_lines_after: int = 0 + ) -> List[MatchedConsecutiveLines]: + """ + Like request_references, but returns the content of the lines containing the references, not just the locations. + + :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + :param context_lines_before: The number of lines to include in the context before the line containing the reference + :param context_lines_after: The number of lines to include in the context after the line containing the reference + + :return: A list of MatchedConsecutiveLines objects, one for each reference. + """ + references = await self.request_references(relative_file_path, line, column) + return [self.retrieve_content_around_line(ref["relativePath"], ref["range"]["start"]["line"], context_lines_before, context_lines_after) for ref in references] + + def retrieve_full_file_content(self, relative_file_path: str) -> str: + """ + Retrieve the full content of the given file. + """ + with self.open_file(relative_file_path) as file_data: + return file_data.contents + + def retrieve_content_around_line(self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0) -> MatchedConsecutiveLines: + """ + Retrieve the content of the given file around the given line. + + :param relative_file_path: The relative path of the file to retrieve the content from + :param line: The line number to retrieve the content around + :param context_lines_before: The number of lines to retrieve before the given line + :param context_lines_after: The number of lines to retrieve after the given line + + :return MatchedConsecutiveLines: A container with the desired lines. + """ + with self.open_file(relative_file_path) as file_data: + file_contents = file_data.contents + return MatchedConsecutiveLines.from_file_contents(file_contents, line=line, context_lines_before=context_lines_before, context_lines_after=context_lines_after, source_file_path=relative_file_path) + + + async def request_completions( + self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False + ) -> List[multilspy_types.CompletionItem]: + """ + Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server + to find completions at the given line and column in the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + + :return List[multilspy_types.CompletionItem]: A list of completions + """ + with self.open_file(relative_file_path): + open_file_buffer = self.open_file_buffers[ + pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + ] + completion_params: LSPTypes.CompletionParams = { + "position": {"line": line, "character": column}, + "textDocument": {"uri": open_file_buffer.uri}, + "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked}, + } + response: Union[List[LSPTypes.CompletionItem], LSPTypes.CompletionList, None] = None + + num_retries = 0 + while response is None or (response["isIncomplete"] and num_retries < 30): + await self.completions_available.wait() + response: Union[ + List[LSPTypes.CompletionItem], LSPTypes.CompletionList, None + ] = await self.server.send.completion(completion_params) + if isinstance(response, list): + response = {"items": response, "isIncomplete": False} + num_retries += 1 + + # TODO: Understand how to appropriately handle `isIncomplete` + if response is None or (response["isIncomplete"] and not(allow_incomplete)): + return [] + + if "items" in response: + response = response["items"] + + response: List[LSPTypes.CompletionItem] = response + + # TODO: Handle the case when the completion is a keyword + items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword] + + completions_list: List[multilspy_types.CompletionItem] = [] + + for item in items: + assert "insertText" in item or "textEdit" in item + assert "kind" in item + completion_item = {} + if "detail" in item: + completion_item["detail"] = item["detail"] + + if "label" in item: + completion_item["completionText"] = item["label"] + completion_item["kind"] = item["kind"] + elif "insertText" in item: + completion_item["completionText"] = item["insertText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "newText" in item["textEdit"]: + completion_item["completionText"] = item["textEdit"]["newText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "range" in item["textEdit"]: + new_dot_lineno, new_dot_colno = ( + completion_params["position"]["line"], + completion_params["position"]["character"], + ) + assert all( + ( + item["textEdit"]["range"]["start"]["line"] == new_dot_lineno, + item["textEdit"]["range"]["start"]["character"] == new_dot_colno, + item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"], + item["textEdit"]["range"]["start"]["character"] + == item["textEdit"]["range"]["end"]["character"], + ) + ) + + completion_item["completionText"] = item["textEdit"]["newText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "insert" in item["textEdit"]: + assert False + else: + assert False + + completion_item = multilspy_types.CompletionItem(**completion_item) + completions_list.append(completion_item) + + return [ + json.loads(json_repr) + for json_repr in set([json.dumps(item, sort_keys=True) for item in completions_list]) + ] + + async def request_document_symbols(self, relative_file_path: str, include_body: bool = False) -> Tuple[List[multilspy_types.UnifiedSymbolInformation], List[multilspy_types.UnifiedSymbolInformation]]: + """ + Raise a [textDocument/documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) request to the Language Server + to find symbols in the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file that has the symbols + :param include_body: whether to include the body of the symbols in the result. + :return: A list of symbols in the file, and a list of root symbols that represent the tree structure of the symbols. + All symbols will have a location, a children, and a parent attribute, + where the parent attribute is None for root symbols. + Note that this is slightly different from the call to request_full_symbol_tree, + where the parent attribute will be the file symbol which in turn may have a package symbol as parent. + If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead. + """ + # TODO: it's kinda dumb to not use the cache if include_body is False after include_body was True once + # Should be fixed in the future, it's a small performance optimization + cache_key = f"{relative_file_path}-{include_body}" + with self.open_file(relative_file_path) as file_data: + with self._cache_lock: + file_hash_and_result = self._document_symbols_cache.get(cache_key) + if file_hash_and_result is not None: + file_hash, result = file_hash_and_result + if file_hash == file_data.content_hash: + self.logger.log(f"Returning cached document symbols for {relative_file_path}", logging.DEBUG) + return result + else: + self.logger.log(f"Content for {relative_file_path} has changed. Will overwrite in-memory cache", logging.DEBUG) + else: + self.logger.log(f"No cache hit for symbols with {include_body=} in {relative_file_path}", logging.DEBUG) + + self.logger.log(f"Requesting document symbols for {relative_file_path} from the Language Server", logging.DEBUG) + response = await self.server.send.document_symbol( + { + "textDocument": { + "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + } + } + ) + self.logger.log(f"Received {len(response) if response is not None else None} document symbols for {relative_file_path} from the Language Server", logging.DEBUG) + + def turn_item_into_symbol_with_children(item: GenericDocumentSymbol): + item = cast(multilspy_types.UnifiedSymbolInformation, item) + absolute_path = os.path.join(self.repository_root_path, relative_file_path) + + # handle missing entries in location + if "location" not in item: + uri = pathlib.Path(absolute_path).as_uri() + assert "range" in item + tree_location = multilspy_types.Location( + uri=uri, + range=item['range'], + absolutePath=absolute_path, + relativePath=relative_file_path, + ) + item['location'] = tree_location + location = item["location"] + if "absolutePath" not in location: + location["absolutePath"] = absolute_path + if "relativePath" not in location: + location["relativePath"] = relative_file_path + if include_body: + item['body'] = self.retrieve_symbol_body(item) + # handle missing selectionRange + if "selectionRange" not in item: + if "range" in item: + item["selectionRange"] = item["range"] + else: + item["selectionRange"] = item["location"]["range"] + children = item.get(LSPConstants.CHILDREN, []) + for child in children: + child["parent"] = item + item[LSPConstants.CHILDREN] = children + + flat_all_symbol_list: List[multilspy_types.UnifiedSymbolInformation] = [] + assert isinstance(response, list), f"Unexpected response from Language Server: {response}" + root_nodes: List[multilspy_types.UnifiedSymbolInformation] = [] + for root_item in response: + if "range" not in root_item and "location" not in root_item: + if root_item["kind"] in [SymbolKind.File, SymbolKind.Module]: + ... + + # mutation is more convenient than creating a new dict, + # so we cast and rename the var after the mutating call to turn_item_into_symbol_with_children + # which turned and item into a "symbol" + turn_item_into_symbol_with_children(root_item) + root_symbol = cast(multilspy_types.UnifiedSymbolInformation, root_item) + root_symbol["parent"] = None + + root_nodes.append(root_symbol) + assert isinstance(root_symbol, dict) + assert LSPConstants.NAME in root_symbol + assert LSPConstants.KIND in root_symbol + + if LSPConstants.CHILDREN in root_symbol: + # TODO: l_tree should be a list of TreeRepr. Define the following function to return TreeRepr as well + + def visit_tree_nodes_and_build_tree_repr(node: GenericDocumentSymbol) -> List[multilspy_types.UnifiedSymbolInformation]: + node = cast(multilspy_types.UnifiedSymbolInformation, node) + l: List[multilspy_types.UnifiedSymbolInformation] = [] + turn_item_into_symbol_with_children(node) + assert LSPConstants.CHILDREN in node + children = node[LSPConstants.CHILDREN] + l.append(node) + for child in children: + l.extend(visit_tree_nodes_and_build_tree_repr(child)) + return l + + flat_all_symbol_list.extend(visit_tree_nodes_and_build_tree_repr(root_symbol)) + else: + flat_all_symbol_list.append(multilspy_types.UnifiedSymbolInformation(**root_symbol)) + + result = flat_all_symbol_list, root_nodes + self.logger.log(f"Caching document symbols for {relative_file_path}", logging.DEBUG) + with self._cache_lock: + self._document_symbols_cache[cache_key] = (file_data.content_hash, result) + self._cache_has_changed = True + return result + + async def request_full_symbol_tree(self, within_relative_path: str | None = None, include_body: bool = False) -> List[multilspy_types.UnifiedSymbolInformation]: + """ + Will go through all files in the project or within a relative path and build a tree of symbols. + Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search. + + For each file, a symbol of kind File (2) will be created. For directories, a symbol of kind Package (4) will be created. + All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project + that are within the repository. + All symbols except the root packages will have a parent attribute. + Will ignore directories starting with '.', language-specific defaults + and user-configured directories (e.g. from .gitignore). + + :param within_relative_path: pass a relative path to only consider symbols within this path. + If a file is passed, only the symbols within this file will be considered. + If a directory is passed, all files within this directory will be considered. + :param include_body: whether to include the body of the symbols in the result. + + :return: A list of root symbols representing the top-level packages/modules in the project. + """ + + if within_relative_path is not None: + within_abs_path = os.path.join(self.repository_root_path, within_relative_path) + if not os.path.exists(within_abs_path): + raise FileNotFoundError(f"File or directory not found: {within_abs_path}") + if os.path.isfile(within_abs_path): + if self.is_ignored_path(within_relative_path): + self.logger.log(f"You passed a file explicitly, but it is ignored. This is probably an error. File: {within_relative_path}", logging.ERROR) + return [] + else: + _, root_nodes = await self.request_document_symbols(within_relative_path, include_body=include_body) + return root_nodes + + # Helper function to recursively process directories + async def process_directory(rel_dir_path: str) -> List[multilspy_types.UnifiedSymbolInformation]: + abs_dir_path = self.repository_root_path if rel_dir_path == "." else os.path.join(self.repository_root_path, rel_dir_path) + abs_dir_path = os.path.realpath(abs_dir_path) + + if self.is_ignored_path(str(Path(abs_dir_path).relative_to(self.repository_root_path))): + self.logger.log(f"Skipping directory: {rel_dir_path}\n(because it should be ignored)", logging.DEBUG) + return [] + + result = [] + try: + contained_dir_or_file_names = os.listdir(abs_dir_path) + except OSError: + return [] + + # Create package symbol for directory + package_symbol = multilspy_types.UnifiedSymbolInformation( # type: ignore + name=os.path.basename(abs_dir_path), + kind=multilspy_types.SymbolKind.Package, + location=multilspy_types.Location( + uri=str(pathlib.Path(abs_dir_path).as_uri()), + range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, + absolutePath=str(abs_dir_path), + relativePath=str(Path(abs_dir_path).resolve().relative_to(self.repository_root_path)), + ), + children=[] + ) + result.append(package_symbol) + + for contained_dir_or_file_name in contained_dir_or_file_names: + contained_dir_or_file_abs_path = os.path.join(abs_dir_path, contained_dir_or_file_name) + contained_dir_or_file_rel_path = str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)) + if self.is_ignored_path(contained_dir_or_file_rel_path): + self.logger.log(f"Skipping item: {contained_dir_or_file_rel_path}\n(because it should be ignored)", logging.DEBUG) + continue + + if os.path.isdir(contained_dir_or_file_abs_path): + child_symbols = await process_directory(contained_dir_or_file_rel_path) + package_symbol["children"].extend(child_symbols) + for child in child_symbols: + child["parent"] = package_symbol + + elif os.path.isfile(contained_dir_or_file_abs_path): + _, file_root_nodes = await self.request_document_symbols(contained_dir_or_file_rel_path, include_body=include_body) + + # Create file symbol, link with children + file_rel_path = str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)) + with self.open_file(file_rel_path) as file_data: + fileRange = self._get_range_from_file_content(file_data.contents) + file_symbol = multilspy_types.UnifiedSymbolInformation( # type: ignore + name=os.path.splitext(contained_dir_or_file_name)[0], + kind=multilspy_types.SymbolKind.File, + range=fileRange, + selectionRange=fileRange, + location=multilspy_types.Location( + uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()), + range=fileRange, + absolutePath=str(contained_dir_or_file_abs_path), + relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)), + ), + children=file_root_nodes, + parent=package_symbol, + ) + for child in file_root_nodes: + child["parent"] = file_symbol + + # Link file symbol with package + package_symbol["children"].append(file_symbol) + + # TODO: Not sure if this is actually still needed given recent changes to relative path handling + def fix_relative_path(nodes: List[multilspy_types.UnifiedSymbolInformation]): + for node in nodes: + if "location" in node and "relativePath" in node["location"]: + path = Path(node["location"]["relativePath"]) + if path.is_absolute(): + try: + path = path.relative_to(self.repository_root_path) + node["location"]["relativePath"] = str(path) + except Exception: + pass + if "children" in node: + fix_relative_path(node["children"]) + + fix_relative_path(file_root_nodes) + + return result + + # Start from the root or the specified directory + start_rel_path = within_relative_path or "." + return await process_directory(start_rel_path) + + @staticmethod + def _get_range_from_file_content(file_content: str) -> multilspy_types.Range: + """ + Get the range for the given file. + """ + lines = file_content.split("\n") + end_line = len(lines) + end_column = len(lines[-1]) + return multilspy_types.Range( + start=multilspy_types.Position(line=0, character=0), + end=multilspy_types.Position(line=end_line, character=end_column) + ) + + async def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[tuple[str, multilspy_types.SymbolKind, int, int]]]: + """ + An overview of the given directory. + + Maps relative paths of all contained files to info about top-level symbols in the file + (name, kind, line, column). + """ + symbol_tree = await self.request_full_symbol_tree(relative_dir_path) + # Initialize result dictionary + result: dict[str, list[tuple[str, multilspy_types.SymbolKind, int, int]]] = defaultdict(list) + + # Helper function to process a symbol and its children + def process_symbol(symbol: multilspy_types.UnifiedSymbolInformation): + if symbol["kind"] == multilspy_types.SymbolKind.File: + # For file symbols, process their children (top-level symbols) + for child in symbol["children"]: + assert "location" in child + assert "selectionRange" in child + path = Path(child["location"]["absolutePath"]).resolve().relative_to(self.repository_root_path) + result[str(path)].append(( + child["name"], + child["kind"], + child["selectionRange"]["start"]["line"], + child["selectionRange"]["start"]["character"] + )) + # For package/directory symbols, process their children + for child in symbol["children"]: + process_symbol(child) + + # Process each root symbol + for root in symbol_tree: + process_symbol(root) + return result + + async def request_document_overview(self, relative_file_path: str) -> list[tuple[str, multilspy_types.SymbolKind, int, int]]: + """ + An overview of the given file. + Returns the list of tuples (name, kind, line, column) of all top-level symbols in the file. + """ + _, document_roots = await self.request_document_symbols(relative_file_path) + result = [] + for root in document_roots: + try: + result.append( + ( root["name"], + root["kind"], + root["selectionRange"]["start"]["line"], + root["selectionRange"]["start"]["character"],) + ) + except KeyError as e: + raise KeyError( + f"Could not process symbol of name {root.get('name', 'unknown')} in {relative_file_path=}" + ) from e + return result + + async def request_overview(self, within_relative_path: str) -> dict[str, list[tuple[str, multilspy_types.SymbolKind, int, int]]]: + """ + An overview of all symbols in the given file or directory. + + :param within_relative_path: the relative path to the file or directory to get the overview of. + :return: A mapping of all relative paths analyzed to lists of tuples (name, kind, line, column) of all top-level symbols in the corresponding file. + """ + abs_path = (Path(self.repository_root_path) / within_relative_path).resolve() + if not abs_path.exists(): + raise FileNotFoundError(f"File or directory not found: {abs_path}") + + if abs_path.is_file(): + symbols_overview = await self.request_document_overview(within_relative_path) + return {within_relative_path: symbols_overview} + else: + return await self.request_dir_overview(within_relative_path) + + async def request_hover(self, relative_file_path: str, line: int, column: int) -> Union[multilspy_types.Hover, None]: + """ + Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server + to find the hover information at the given line and column in the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file that has the hover information + :param line: The line number of the symbol + :param column: The column number of the symbol + + :return None + """ + with self.open_file(relative_file_path): + response = await self.server.send.hover( + { + "textDocument": { + "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + }, + "position": { + "line": line, + "character": column, + }, + } + ) + + if response is None: + return None + + assert isinstance(response, dict) + + return multilspy_types.Hover(**response) + + # ----------------------------- FROM HERE ON MODIFICATIONS BY MISCHA -------------------- + + def retrieve_symbol_body(self, symbol: multilspy_types.UnifiedSymbolInformation | LSPTypes.DocumentSymbol | LSPTypes.SymbolInformation) -> str: + """ + Load the body of the given symbol. If the body is already contained in the symbol, just return it. + """ + existing_body = symbol.get("body", None) + if existing_body: + return existing_body + + assert "location" in symbol + symbol_start_line = symbol["location"]["range"]["start"]["line"] + symbol_end_line = symbol["location"]["range"]["end"]["line"] + assert "relativePath" in symbol["location"] + symbol_file = self.retrieve_full_file_content(symbol["location"]["relativePath"]) + symbol_lines = symbol_file.split("\n") + symbol_body = "\n".join(symbol_lines[symbol_start_line:symbol_end_line+1]) + + # remove leading indentation + symbol_start_column = symbol["location"]["range"]["start"]["character"] + symbol_body = symbol_body[symbol_start_column:] + return symbol_body + + async def request_parsed_files(self) -> list[str]: + """Retrieves relative paths of all files analyzed by the Language Server.""" + if not self.server_started: + self.logger.log( + "request_parsed_files called before Language Server started", + logging.ERROR, + ) + raise MultilspyException("Language Server not started") + rel_file_paths = [] + for root, dirs, files in os.walk(self.repository_root_path): + dirs[:] = [d for d in dirs if not self.is_ignored_path(os.path.join(root, d))] + for file in files: + rel_file_path = os.path.relpath(os.path.join(root, file), start=self.repository_root_path) + if not self.is_ignored_path(rel_file_path): + rel_file_paths.append(rel_file_path) + return rel_file_paths + + async def search_files_for_pattern( + self, + pattern: re.Pattern | str, + context_lines_before: int = 0, + context_lines_after: int = 0, + paths_include_glob: str | None = None, + paths_exclude_glob: str | None = None, + ) -> list[MatchedConsecutiveLines]: + """ + Search for a pattern across all files analyzed by the Language Server. + + :param pattern: Regular expression pattern to search for, either as a compiled Pattern or string + :param context_lines_before: Number of lines of context to include before each match + :param context_lines_after: Number of lines of context to include after each match + :param paths_include_glob: Glob pattern to filter which files to include in the search + :param paths_exclude_glob: Glob pattern to filter which files to exclude from the search. Takes precedence over paths_include_glob. + :return: List of matched consecutive lines with context + """ + if isinstance(pattern, str): + pattern = re.compile(pattern) + + relative_file_paths = await self.request_parsed_files() + return search_files( + relative_file_paths, + pattern, + file_reader=self.retrieve_full_file_content, + context_lines_before=context_lines_before, + context_lines_after=context_lines_after, + paths_include_glob=paths_include_glob, + paths_exclude_glob=paths_exclude_glob + ) + + async def request_referencing_symbols( + self, + relative_file_path: str, + line: int, + column: int, + include_imports: bool = True, + include_self: bool = False, + include_body: bool = False, + include_file_symbols: bool = False, + ) -> List[ReferenceInSymbol]: + """ + Finds all symbols that reference the symbol at the given location. + This is similar to request_references but filters to only include symbols + (functions, methods, classes, etc.) that reference the target symbol. + + :param relative_file_path: The relative path to the file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column number. + :param include_imports: whether to also include imports as references. + Unfortunately, the LSP does not have an import type, so the references corresponding to imports + will not be easily distinguishable from definitions. + :param include_self: whether to include the references that is the "input symbol" itself. + Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition. + :param include_body: whether to include the body of the symbols in the result. + :param include_file_symbols: whether to include references that are file symbols. This + is often a fallback mechanism for when the reference cannot be resolved to a symbol. + :return: List of objects containing the symbol and the location of the reference. + """ + if not self.server_started: + self.logger.log( + "request_referencing_symbols called before Language Server started", + logging.ERROR, + ) + raise MultilspyException("Language Server not started") + + # First, get all references to the symbol + references = await self.request_references(relative_file_path, line, column) + if not references: + return [] + + # For each reference, find the containing symbol + result = [] + incoming_symbol = None + for ref in references: + ref_path = ref["relativePath"] + ref_line = ref["range"]["start"]["line"] + ref_col = ref["range"]["start"]["character"] + + with self.open_file(ref_path) as file_data: + # Get the containing symbol for this reference + containing_symbol = await self.request_containing_symbol( + ref_path, ref_line, ref_col, include_body=include_body + ) + if containing_symbol is None: + # TODO: HORRIBLE HACK! I don't know how to do it better for now... + # THIS IS BOUND TO BREAK IN MANY CASES! IT IS ALSO SPECIFIC TO PYTHON! + # Background: + # When a variable is used to change something, like + # + # instance = MyClass() + # instance.status = "new status" + # + # we can't find the containing symbol for the reference to `status` + # since there is no container on the line of the reference + # The hack is to try to find a variable symbol in the containing module + # by using the text of the reference to find the variable name (In a very heuristic way) + # and then look for a symbol with that name and kind Variable + ref_text = file_data.contents.split("\n")[ref_line] + if "." in ref_text: + containing_symbol_name = ref_text.split(".")[0] + all_symbols, _ = await self.request_document_symbols(ref_path) + for symbol in all_symbols: + if symbol["name"] == containing_symbol_name and symbol["kind"] == multilspy_types.SymbolKind.Variable: + containing_symbol = copy(symbol) + containing_symbol["location"] = ref + containing_symbol["range"] = ref["range"] + break + + # We failed retrieving the symbol, falling back to creating a file symbol + if containing_symbol is None and include_file_symbols: + self.logger.log( + f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead", + logging.WARNING + ) + fileRange = self._get_range_from_file_content(file_data.contents) + location = multilspy_types.Location( + uri=str(pathlib.Path(os.path.join(self.repository_root_path, ref_path)).as_uri()), + range=fileRange, + absolutePath=str(os.path.join(self.repository_root_path, ref_path)), + relativePath=ref_path, + ) + name = os.path.splitext(os.path.basename(ref_path))[0] + + if include_body: + body = self.retrieve_full_file_content(ref_path) + else: + body = "" + + containing_symbol = multilspy_types.UnifiedSymbolInformation( + kind=multilspy_types.SymbolKind.File, + range=fileRange, + selectionRange=fileRange, + location=location, + name=name, + children=[], + body=body, + ) + if containing_symbol is None or not include_file_symbols and containing_symbol["kind"] == multilspy_types.SymbolKind.File: + continue + + assert "location" in containing_symbol + assert "selectionRange" in containing_symbol + + # Checking for self-reference + if ( + containing_symbol["location"]["relativePath"] == relative_file_path + and containing_symbol["selectionRange"]["start"]["line"] == ref_line + and containing_symbol["selectionRange"]["start"]["character"] == ref_col + ): + incoming_symbol = containing_symbol + if include_self: + result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) + continue + else: + self.logger.log(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}", logging.DEBUG) + continue + + # checking whether reference is an import + # This is neither really safe nor elegant, but if we don't do it, + # there is no way to distinguish between definitions and imports as import is not a symbol-type + # and we get the type referenced symbol resulting from imports... + if (not include_imports \ + and incoming_symbol is not None \ + and containing_symbol["name"] == incoming_symbol["name"] \ + and containing_symbol["kind"] == incoming_symbol["kind"] \ + ): + self.logger.log( + f"Found import of referenced symbol {incoming_symbol['name']}" + f"in {containing_symbol['location']['relativePath']}, skipping", + logging.DEBUG + ) + continue + + result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) + + return result + + async def request_containing_symbol( + self, + relative_file_path: str, + line: int, + column: Optional[int] = None, + strict: bool = False, + include_body: bool = False, + ) -> multilspy_types.UnifiedSymbolInformation | None: + """ + Finds the first symbol containing the position for the given file. + For Python, container symbols are considered to be those with kinds corresponding to + functions, methods, or classes (typically: Function (12), Method (6), Class (5)). + + The method operates as follows: + - Request the document symbols for the file. + - Filter symbols to those that start at or before the given line. + - From these, first look for symbols whose range contains the (line, column). + - If one or more symbols contain the position, return the one with the greatest starting position + (i.e. the innermost container). + - If none (strictly) contain the position, return the symbol with the greatest starting position + among those above the given line. + - If no container candidates are found, return None. + + :param relative_file_path: The relative path to the Python file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column (also called character). If not passed, the lookup will be based + only on the line. + :param strict: If True, the position must be strictly within the range of the symbol. + Setting to True is useful for example for finding the parent of a symbol, as with strict=False, + and the line pointing to a symbol itself, the containing symbol will be the symbol itself + (and not the parent). + :param include_body: Whether to include the body of the symbol in the result. + :return: The container symbol (if found) or None. + """ + # checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor + with self.open_file(relative_file_path): + absolute_file_path = str( + PurePath(self.repository_root_path, relative_file_path) + ) + content = FileUtils.read_file(self.logger, absolute_file_path) + if content.split("\n")[line].strip() == "": + self.logger.log( + f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}", + logging.ERROR, + ) + return None + + symbols, _ = await self.request_document_symbols(relative_file_path) + + # make jedi and pyright api compatible + # the former has no location, the later has no range + # we will just always add location of the desired format to all symbols + for symbol in symbols: + if "location" not in symbol: + range = symbol["range"] + location = multilspy_types.Location( + uri=f"file:/{absolute_file_path}", + range=range, + absolutePath=absolute_file_path, + relativePath=relative_file_path, + ) + symbol["location"] = location + else: + location = symbol["location"] + assert "range" in location + location["absolutePath"] = absolute_file_path + location["relativePath"] = relative_file_path + location["uri"] = Path(absolute_file_path).as_uri() + + # Allowed container kinds, currently only for Python + container_symbol_kinds = { + multilspy_types.SymbolKind.Method, + multilspy_types.SymbolKind.Function, + multilspy_types.SymbolKind.Class + } + + def is_position_in_range(line: int, range_d: multilspy_types.Range) -> bool: + start = range_d["start"] + end = range_d["end"] + + column_condition = True + if strict: + line_condition = end["line"] >= line > start["line"] + if column is not None and line == start["line"]: + column_condition = column > start["character"] + else: + line_condition = end["line"] >= line >= start["line"] + if column is not None and line == start["line"]: + column_condition = column >= start["character"] + return line_condition and column_condition + + # Only consider containers that are not one-liners (otherwise we may get imports) + candidate_containers = [ + s for s in symbols if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"] + ] + var_containers = [ + s for s in symbols if s["kind"] == multilspy_types.SymbolKind.Variable + ] + candidate_containers.extend(var_containers) + + if not candidate_containers: + return None + + # From the candidates, find those whose range contains the given position. + containing_symbols = [] + for symbol in candidate_containers: + s_range = symbol["location"]["range"] + if not is_position_in_range(line, s_range): + continue + containing_symbols.append(symbol) + + if containing_symbols: + # Return the one with the greatest starting position (i.e. the innermost container). + containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"]) + if include_body: + containing_symbol["body"] = self.retrieve_symbol_body(containing_symbol) + return containing_symbol + else: + return None + + async def request_container_of_symbol(self, symbol: multilspy_types.UnifiedSymbolInformation, include_body: bool = False) -> multilspy_types.UnifiedSymbolInformation | None: + """ + Finds the container of the given symbol if there is one. If the parent attribute is present, the parent is returned + without further searching. + + :param symbol: The symbol to find the container of. + :param include_body: whether to include the body of the symbol in the result. + :return: The container of the given symbol or None if no container is found. + """ + if "parent" in symbol: + return symbol["parent"] + assert "location" in symbol, f"Symbol {symbol} has no location and no parent attribute" + return await self.request_containing_symbol( + symbol["location"]["relativePath"], + symbol["location"]["range"]["start"]["line"], + symbol["location"]["range"]["start"]["character"], + strict=True, + include_body=include_body, + ) + + async def request_defining_symbol( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> Optional[multilspy_types.UnifiedSymbolInformation]: + """ + Finds the symbol that defines the symbol at the given location. + + This method first finds the definition of the symbol at the given position, + then retrieves the full symbol information for that definition. + + :param relative_file_path: The relative path to the file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column number. + :param include_body: whether to include the body of the symbol in the result. + :return: The symbol information for the definition, or None if not found. + """ + if not self.server_started: + self.logger.log( + "request_defining_symbol called before Language Server started", + logging.ERROR, + ) + raise MultilspyException("Language Server not started") + + # Get the definition location(s) + definitions = await self.request_definition(relative_file_path, line, column) + if not definitions: + return None + + # Use the first definition location + definition = definitions[0] + def_path = definition["relativePath"] + def_line = definition["range"]["start"]["line"] + def_col = definition["range"]["start"]["character"] + + # Find the symbol at or containing this location + defining_symbol = await self.request_containing_symbol( + def_path, def_line, def_col, strict=False, include_body=include_body + ) + + return defining_symbol + + @property + def cache_path(self) -> Path: + """ + The path to the cache file for the document symbols. + """ + return Path(self.repository_root_path) / ".serena" / "cache" / self.language_id / "document_symbols_cache_v20-05-25.pkl" + + async def index_repository(self, progress_bar: bool = True, save_after_n_files: int = 10) -> None: + """Will go through the entire repository and "index" all files, meaning save their symbols to the cache. + + :param progress_bar: Whether to show a progress bar while indexing the repository. + :param save_after_n_files: How many files to process before saving a checkpoint of the cache. + """ + parsed_files = await self.request_parsed_files() + files_processed = 0 + pbar = tqdm.tqdm(parsed_files, disable=not progress_bar) + for relative_file_path in pbar: + pbar.set_description(f"Indexing ({os.path.basename(relative_file_path)})") + await self.request_document_symbols(relative_file_path, include_body=False) + await self.request_document_symbols(relative_file_path, include_body=True) + files_processed += 1 + if files_processed % save_after_n_files == 0: + self.save_cache() + self.save_cache() + + def save_cache(self): + with self._cache_lock: + if not self._cache_has_changed: + self.logger.log("No changes to document symbols cache, skipping save", logging.DEBUG) + return + + self.logger.log(f"Saving updated document symbols cache to {self.cache_path}", logging.INFO) + self.cache_path.parent.mkdir(parents=True, exist_ok=True) + try: + with open(self.cache_path, "wb") as f: + pickle.dump(self._document_symbols_cache, f) + self._cache_has_changed = False + except Exception as e: + self.logger.log( + f"Failed to save document symbols cache to {self.cache_path}: {e}. " + "Note: this may have resulted in a corrupted cache file.", + logging.ERROR, + ) + + def load_cache(self): + if not self.cache_path.exists(): + return + + with self._cache_lock: + self.logger.log(f"Loading document symbols cache from {self.cache_path}", logging.INFO) + try: + with open(self.cache_path, "rb") as f: + self._document_symbols_cache = pickle.load(f) + self.logger.log(f"Loaded {len(self._document_symbols_cache)} document symbols from cache.", logging.INFO) + except Exception as e: + # cache often becomes corrupt, so just skip loading it + self.logger.log( + f"Failed to load document symbols cache from {self.cache_path}: {e}. Possible cause: the cache file is corrupted. " + "Check for any errors related to saving the cache in the logs.", + logging.ERROR, + ) + + + async def request_workspace_symbol(self, query: str) -> Union[List[multilspy_types.UnifiedSymbolInformation], None]: + """ + Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server + to find symbols across the whole workspace. Wait for the response and return the result. + + :param query: The query string to filter symbols by + + :return: A list of matching symbols + """ + response = await self.server.send.workspace_symbol({"query": query}) + if response is None: + return None + + assert isinstance(response, list) + + ret: List[multilspy_types.UnifiedSymbolInformation] = [] + for item in response: + assert isinstance(item, dict) + + assert LSPConstants.NAME in item + assert LSPConstants.KIND in item + assert LSPConstants.LOCATION in item + + ret.append(multilspy_types.UnifiedSymbolInformation(**item)) + + return ret + + +class PyrightServer(LanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class using Pyright. + Contains various configurations and settings specific to Python. + """ + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a PyrightServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + logger, + repository_root_path, + # Note 1: we can also use `pyright-langserver --stdio` but it requires pyright to be installed with npm + # Note 2: we can also use `bpyright-langserver --stdio` if we ever are unhappy with pyright + ProcessLaunchInfo(cmd="python -m pyright.langserver --stdio", cwd=repository_root_path), + "python", + ) + + # Event to signal when initial workspace analysis is complete + self.analysis_complete = asyncio.Event() + self.found_source_files = False + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Pyright Language Server. + """ + # Create basic initialization parameters + initialize_params: InitializeParams = { # type: ignore + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": pathlib.Path(repository_absolute_path).as_uri(), + "initializationOptions": { + "exclude": [ + "**/__pycache__", + "**/.venv", + "**/.env", + "**/build", + "**/dist", + "**/.pixi", + ], + "reportMissingImports": "error", + }, + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + }, + "executeCommand": {"dynamicRegistration": True}, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + "hierarchicalDocumentSymbolSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + }, + "workspaceFolders": [ + {"uri": pathlib.Path(repository_absolute_path).as_uri(), "name": os.path.basename(repository_absolute_path)} + ], + } + + return initialize_params + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["PyrightServer"]: + """ + Starts the Pyright Language Server and waits for initial workspace analysis to complete. + + This prevents zombie processes by ensuring Pyright has finished its initial background + tasks before we consider the server ready. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and workspace analysis is complete + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown cleanly + ``` + """ + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def window_log_message(msg): + """ + Monitor Pyright's log messages to detect when initial analysis is complete. + Pyright logs "Found X source files" when it finishes scanning the workspace. + """ + message_text = msg.get("message", "") + self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) + + # Look for "Found X source files" which indicates workspace scanning is complete + # Unfortunately, pyright is unreliable and there seems to be no better way + if re.search(r"Found \d+ source files?", message_text): + self.logger.log("Pyright workspace scanning complete", logging.INFO) + self.found_source_files = True + self.analysis_complete.set() + self.completions_available.set() + + async def check_experimental_status(params): + """ + Also listen for experimental/serverStatus as a backup signal + """ + if params.get("quiescent") == True: + self.logger.log("Received experimental/serverStatus with quiescent=true", logging.INFO) + if not self.found_source_files: + self.analysis_complete.set() + self.completions_available.set() + + # Set up notification handlers + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + async with super().start_server(): + self.logger.log("Starting pyright-langserver server process", logging.INFO) + await self.server.start() + + # Send proper initialization parameters + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to pyright server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) + + # Verify that the server supports our required features + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + # Complete the initialization handshake + self.server.notify.initialized({}) + + # Wait for Pyright to complete its initial workspace analysis + # This prevents zombie processes by ensuring background tasks finish + self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) + try: + await asyncio.wait_for(self.analysis_complete.wait(), timeout=1.0) + self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) + except asyncio.TimeoutError: + self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) + # Fallback: assume analysis is complete after timeout + self.analysis_complete.set() + self.completions_available.set() + + yield self diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_handler.py new file mode 100644 index 00000000..91af0177 --- /dev/null +++ b/src/solidlsp/ls_handler.py @@ -0,0 +1,531 @@ +import asyncio +import dataclasses +import json +import logging +import os +import subprocess +import threading + +import psutil +from typing import Any, Callable, Dict, List, Optional, Union + +from multilspy.lsp_protocol_handler.lsp_requests import LspNotification, LspRequest +from multilspy.lsp_protocol_handler.lsp_types import ErrorCodes +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict, Request, content_length, ENCODING, Error, MessageType, \ + make_notification, PayloadLike, make_response, make_error_response, make_request, create_message +from multilspy.multilspy_exceptions import MultilspyException + + +log = logging.getLogger(__name__) + + +class LanguageServerHandler: + """ + This class provides the implementation of Python client for the Language Server Protocol. + A class that launches the language server and communicates with it + using the Language Server Protocol (LSP). + + It provides methods for sending requests, responses, and notifications to the server + and for registering handlers for requests and notifications from the server. + + Uses JSON-RPC 2.0 for communication with the server over stdin/stdout. + + Attributes: + send: A LspRequest object that can be used to send requests to the server and + await for the responses. + notify: A LspNotification object that can be used to send notifications to the server. + cmd: A string that represents the command to launch the language server process. + process: A subprocess.Popen object that represents the language server process. + _received_shutdown: A boolean flag that indicates whether the client has received + a shutdown request from the server. + request_id: An integer that represents the next available request id for the client. + _response_handlers: A dictionary that maps request ids to Request objects that + store the results or errors of the requests. + on_request_handlers: A dictionary that maps method names to callback functions + that handle requests from the server. + on_notification_handlers: A dictionary that maps method names to callback functions + that handle notifications from the server. + logger: An optional function that takes two strings (source and destination) and + a payload dictionary, and logs the communication between the client and the server. + tasks: A dictionary that maps task ids to asyncio.Task objects that represent + the asynchronous tasks created by the handler. + task_counter: An integer that represents the next available task id for the handler. + loop: An asyncio.AbstractEventLoop object that represents the event loop used by the handler. + start_independent_lsp_process: An optional boolean flag that indicates whether to start the + language server process in an independent process group. Default is `True`. Setting it to + `False` means that the language server process will be in the same process group as the + the current process, and any SIGINT and SIGTERM signals will be sent to both processes. + """ + + def __init__( + self, + process_launch_info: ProcessLaunchInfo, + logger: Optional[Callable[[str, str, StringDict | str], None]] = None, + start_independent_lsp_process=True, + ) -> None: + """ + Params: + cmd: A string that represents the command to launch the language server process. + logger: An optional function that takes two strings (source and destination) and + a payload dictionary, and logs the communication between the client and the server. + """ + self.send = LspRequest(self.send_request) + self.notify = LspNotification(self.send_notification) + + self.process_launch_info = process_launch_info + self.process = None + self._received_shutdown = False + + self.request_id = 1 + self._response_handlers: Dict[Any, Request] = {} + self.on_request_handlers = {} + self.on_notification_handlers = {} + self.logger = logger + self.tasks = {} + self.task_counter = 0 + self.loop = None + self.start_independent_lsp_process = start_independent_lsp_process + + # Add thread locks for shared resources to prevent race conditions + self._stdin_lock = threading.Lock() + self._request_id_lock = threading.Lock() + self._response_handlers_lock = threading.Lock() + self._tasks_lock = threading.Lock() + + + + def is_running(self) -> bool: + """ + Checks if the language server process is currently running. + """ + return self.process is not None and self.process.returncode is None + + async def start(self) -> None: + """ + Starts the language server process and creates a task to continuously read from its stdout to handle communications + from the server to the client + """ + child_proc_env = os.environ.copy() + child_proc_env.update(self.process_launch_info.env) + + log.info("Starting language server process via command: %s", self.process_launch_info.cmd) + self.process = await asyncio.create_subprocess_shell( + self.process_launch_info.cmd, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=child_proc_env, + cwd=self.process_launch_info.cwd, + start_new_session=self.start_independent_lsp_process, + ) + + # Check if process terminated immediately + if self.process.returncode is not None: + log.error("Language server has already terminated/could not be started") + # Process has already terminated + stderr_data = await self.process.stderr.read() + error_message = stderr_data.decode('utf-8', errors='replace') + raise RuntimeError(f"Process terminated immediately with code {self.process.returncode}. Error: {error_message}") + + self.loop = asyncio.get_event_loop() + + # Use lock to prevent race conditions on tasks and task_counter during startup + with self._tasks_lock: + self.tasks[self.task_counter] = self.loop.create_task(self.run_forever()) + self.task_counter += 1 + self.tasks[self.task_counter] = self.loop.create_task(self.run_forever_stderr()) + self.task_counter += 1 + + + + async def stop(self) -> None: + """ + Sends the terminate signal to the language server process and waits for it to exit, with a timeout, killing it if necessary + """ + # First cancel all tasks + await self._cancel_pending_tasks() + + process = self.process + self.process = None + + if not process: + return + + # Clean up the process + await self._cleanup_process(process) + + async def _cancel_pending_tasks(self): + """Cancel all pending tasks and wait for them to complete or timeout.""" + pending_tasks = [] + + # Use lock to safely access tasks dictionary + with self._tasks_lock: + for task in self.tasks.values(): + if not task.done(): + task.cancel() + pending_tasks.append(task) + + if pending_tasks: + try: + await asyncio.wait_for(asyncio.gather(*pending_tasks, return_exceptions=True), timeout=5.0) + except (asyncio.TimeoutError, Exception): + pass + + # Clear tasks dictionary under lock + with self._tasks_lock: + self.tasks = {} + + async def _cleanup_process(self, process): + """Clean up a process: close stdin, terminate/kill process, close stdout/stderr.""" + # Close stdin first to prevent deadlocks + # See: https://bugs.python.org/issue35539 + self._safely_close_pipe(process.stdin) + + # Terminate/kill the process if it's still running + if process.returncode is None: + await self._terminate_or_kill_process(process) + + # Close stdout and stderr pipes after process has exited + # This is essential to prevent "I/O operation on closed pipe" errors and + # "Event loop is closed" errors during garbage collection + # See: https://bugs.python.org/issue41320 and https://github.com/python/cpython/issues/88050 + self._safely_close_pipe(process.stdout) + self._safely_close_pipe(process.stderr) + + # Small delay to ensure OS has released file handles + await asyncio.sleep(0.5) + + def _safely_close_pipe(self, pipe): + """Safely close a pipe, ignoring any exceptions.""" + if pipe: + try: + pipe.close() + except Exception: + pass + + async def _terminate_or_kill_process(self, process): + """Try to terminate the process gracefully, then forcefully if necessary.""" + # First try to terminate the process tree gracefully + self._signal_process_tree(process, terminate=True) + + # Wait for the process to exit (with timeout) + try: + await asyncio.wait_for(process.wait(), timeout=10) + except (asyncio.TimeoutError, Exception): + # If termination failed, forcefully kill the process tree + self._signal_process_tree(process, terminate=False) + try: + # Give it one more chance to exit + await asyncio.wait_for(process.wait(), timeout=2) + except Exception: + pass + + def _signal_process_tree(self, process, terminate=True): + """Send signal (terminate or kill) to the process and all its children.""" + signal_method = "terminate" if terminate else "kill" + + # Try to get the parent process + parent = None + try: + parent = psutil.Process(process.pid) + except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): + pass + + # If we have the parent process and it's running, signal the entire tree + if parent and parent.is_running(): + # Signal children first + for child in parent.children(recursive=True): + try: + getattr(child, signal_method)() + except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): + pass + + # Then signal the parent + try: + getattr(parent, signal_method)() + except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): + pass + else: + # Fall back to direct process signaling + try: + getattr(process, signal_method)() + except Exception: + pass + + + async def shutdown(self) -> None: + """ + Perform the shutdown sequence for the client, including sending the shutdown request to the server and notifying it of exit + """ + self._log("Sending shutdown request to server") + await self.send.shutdown() + self._log("Received shutdown response from server") + self._received_shutdown = True + self._log("Sending exit notification to server") + self.notify.exit() + self._log("Sent exit notification to server") + if self.process and self.process.stdout: + self.process.stdout.set_exception(StopLoopException()) + # This yields the control to the event loop to allow the exception to be handled + # in the run_forever and run_forever_stderr methods + await asyncio.sleep(0) + + def _log(self, message: str | StringDict) -> None: + """ + Create a log message + """ + if self.logger is not None: + self.logger("client", "logger", message) + + async def run_forever(self) -> bool: + """ + Continuously read from the language server process stdout and handle the messages + invoking the registered response and notification handlers + """ + try: + while self.process and self.process.stdout and not self.process.stdout.at_eof(): + line = await self.process.stdout.readline() + if not line: + continue + try: + num_bytes = content_length(line) + except ValueError: + continue + if num_bytes is None: + continue + while line and line.strip(): + line = await self.process.stdout.readline() + if not line: + continue + body = await self.process.stdout.readexactly(num_bytes) + + # Use lock to prevent race conditions on tasks and task_counter + with self._tasks_lock: + self.tasks[self.task_counter] = asyncio.get_event_loop().create_task(self._handle_body(body)) + self.task_counter += 1 + except (BrokenPipeError, ConnectionResetError, StopLoopException): + pass + return self._received_shutdown + + + async def run_forever_stderr(self) -> None: + """ + Continuously read from the language server process stderr and log the messages + """ + try: + while self.process and self.process.stderr and not self.process.stderr.at_eof(): + line = await self.process.stderr.readline() + if not line: + continue + self._log("LSP stderr: " + line.decode(ENCODING, errors='replace')) + except (BrokenPipeError, ConnectionResetError, StopLoopException): + pass + + async def _handle_body(self, body: bytes) -> None: + """ + Parse the body text received from the language server process and invoke the appropriate handler + """ + try: + await self._receive_payload(json.loads(body)) + except IOError as ex: + self._log(f"malformed {ENCODING}: {ex}") + except UnicodeDecodeError as ex: + self._log(f"malformed {ENCODING}: {ex}") + except json.JSONDecodeError as ex: + self._log(f"malformed JSON: {ex}") + + async def _receive_payload(self, payload: StringDict) -> None: + """ + Determine if the payload received from server is for a request, response, or notification and invoke the appropriate handler + """ + if self.logger: + self.logger("server", "client", payload) + try: + if "method" in payload: + if "id" in payload: + await self._request_handler(payload) + else: + await self._notification_handler(payload) + elif "id" in payload: + await self._response_handler(payload) + else: + self._log(f"Unknown payload type: {payload}") + except Exception as err: + self._log(f"Error handling server payload: {err}") + + def send_notification(self, method: str, params: Optional[dict] = None) -> None: + """ + Send notification pertaining to the given method to the server with the given parameters + """ + self._send_payload_sync(make_notification(method, params)) + + def send_response(self, request_id: Any, params: PayloadLike) -> None: + """ + Send response to the given request id to the server with the given parameters + """ + # Use lock to prevent race conditions on tasks and task_counter + with self._tasks_lock: + self.tasks[self.task_counter] = asyncio.get_event_loop().create_task( + self._send_payload(make_response(request_id, params)) + ) + self.task_counter += 1 + + + def send_error_response(self, request_id: Any, err: Error) -> None: + """ + Send error response to the given request id to the server with the given error + """ + # Use lock to prevent race conditions on tasks and task_counter + with self._tasks_lock: + self.tasks[self.task_counter] = asyncio.get_event_loop().create_task( + self._send_payload(make_error_response(request_id, err)) + ) + self.task_counter += 1 + + + async def send_request(self, method: str, params: Optional[dict] = None) -> PayloadLike: + """ + Send request to the server, register the request id, and wait for the response + """ + request = Request() + + # Use lock to prevent race conditions on request_id and _response_handlers + with self._request_id_lock: + request_id = self.request_id + self.request_id += 1 + + with self._response_handlers_lock: + self._response_handlers[request_id] = request + + async with request.cv: + await self._send_payload(make_request(method, request_id, params)) + self._log(f"Waiting for asyncio condition for request {method} with params:\n{params}") + await request.cv.wait() + self._log(f"Finished waiting, processing result") + if isinstance(request.error, Error): + raise MultilspyException(f"Could not process request {method} with params:\n{params}.\n Language server error: {request.error}") from request.error + self._log(f"Returning non-error result, which is:\n{request.result}") + return request.result + + + def _send_payload_sync(self, payload: StringDict) -> None: + """ + Send the payload to the server by writing to its stdin synchronously + """ + if not self.process or not self.process.stdin: + return + msg = create_message(payload) + if self.logger: + self.logger("client", "server", payload) + + # Use lock to prevent concurrent writes to stdin that cause buffer corruption + with self._stdin_lock: + try: + self.process.stdin.writelines(msg) + except (BrokenPipeError, ConnectionResetError, OSError) as e: + # Log the error but don't raise to prevent cascading failures + if self.logger: + self.logger("client", "logger", f"Failed to write to stdin: {e}") + return + + + async def _send_payload(self, payload: StringDict) -> None: + """ + Send the payload to the server by writing to its stdin asynchronously. + """ + if not self.process or not self.process.stdin: + return + self._log(payload) + msg = create_message(payload) + + # Use lock to prevent concurrent writes to stdin that cause buffer corruption + with self._stdin_lock: + try: + self.process.stdin.writelines(msg) + await self.process.stdin.drain() + except (BrokenPipeError, ConnectionResetError, OSError) as e: + # Log the error but don't raise to prevent cascading failures + if self.logger: + self.logger("client", "logger", f"Failed to write to stdin: {e}") + return + + + def on_request(self, method: str, cb) -> None: + """ + Register the callback function to handle requests from the server to the client for the given method + """ + self.on_request_handlers[method] = cb + + def on_notification(self, method: str, cb) -> None: + """ + Register the callback function to handle notifications from the server to the client for the given method + """ + self.on_notification_handlers[method] = cb + + async def _response_handler(self, response: StringDict) -> None: + """ + Handle the response received from the server for a request, using the id to determine the request + """ + with self._response_handlers_lock: + request = self._response_handlers.pop(response["id"]) + + if "result" in response and "error" not in response: + await request.on_result(response["result"]) + elif "result" not in response and "error" in response: + await request.on_error(Error.from_lsp(response["error"])) + else: + await request.on_error(Error(ErrorCodes.InvalidRequest, "")) + + + async def _request_handler(self, response: StringDict) -> None: + """ + Handle the request received from the server: call the appropriate callback function and return the result + """ + method = response.get("method", "") + params = response.get("params") + request_id = response.get("id") + handler = self.on_request_handlers.get(method) + if not handler: + self.send_error_response( + request_id, + Error( + ErrorCodes.MethodNotFound, + "method '{}' not handled on client.".format(method), + ), + ) + return + try: + self.send_response(request_id, await handler(params)) + except Error as ex: + self.send_error_response(request_id, ex) + except Exception as ex: + self.send_error_response(request_id, Error(ErrorCodes.InternalError, str(ex))) + + async def _notification_handler(self, response: StringDict) -> None: + """ + Handle the notification received from the server: call the appropriate callback function + """ + method = response.get("method", "") + params = response.get("params") + handler = self.on_notification_handlers.get(method) + if not handler: + self._log(f"unhandled {method}") + return + try: + await handler(params) + except asyncio.CancelledError: + return + except Exception as ex: + if (not self._received_shutdown) and self.logger: + self.logger( + "client", + "logger", + str( + { + "type": MessageType.error, + "message": str(ex), + "method": method, + "params": params, + } + ), + ) diff --git a/src/solidlsp/lsp_request.py b/src/solidlsp/lsp_request.py new file mode 100644 index 00000000..b46bf346 --- /dev/null +++ b/src/solidlsp/lsp_request.py @@ -0,0 +1,435 @@ +from typing import List, Union +from multilspy.lsp_protocol_handler import lsp_types + + +class LspRequest: + def __init__(self, send_request): + self.send_request = send_request + + async def implementation( + self, params: lsp_types.ImplementationParams + ) -> Union["lsp_types.Definition", List["lsp_types.LocationLink"], None]: + """A request to resolve the implementation locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Definition} or a + Thenable that resolves to such.""" + return await self.send_request("textDocument/implementation", params) + + async def type_definition( + self, params: lsp_types.TypeDefinitionParams + ) -> Union["lsp_types.Definition", List["lsp_types.LocationLink"], None]: + """A request to resolve the type definition locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Definition} or a + Thenable that resolves to such.""" + return await self.send_request("textDocument/typeDefinition", params) + + async def document_color( + self, params: lsp_types.DocumentColorParams + ) -> List["lsp_types.ColorInformation"]: + """A request to list all color symbols found in a given text document. The request's + parameter is of type {@link DocumentColorParams} the + response is of type {@link ColorInformation ColorInformation[]} or a Thenable + that resolves to such.""" + return await self.send_request("textDocument/documentColor", params) + + async def color_presentation( + self, params: lsp_types.ColorPresentationParams + ) -> List["lsp_types.ColorPresentation"]: + """A request to list all presentation for a color. The request's + parameter is of type {@link ColorPresentationParams} the + response is of type {@link ColorInformation ColorInformation[]} or a Thenable + that resolves to such.""" + return await self.send_request("textDocument/colorPresentation", params) + + async def folding_range( + self, params: lsp_types.FoldingRangeParams + ) -> Union[List["lsp_types.FoldingRange"], None]: + """A request to provide folding ranges in a document. The request's + parameter is of type {@link FoldingRangeParams}, the + response is of type {@link FoldingRangeList} or a Thenable + that resolves to such.""" + return await self.send_request("textDocument/foldingRange", params) + + async def declaration( + self, params: lsp_types.DeclarationParams + ) -> Union["lsp_types.Declaration", List["lsp_types.LocationLink"], None]: + """A request to resolve the type definition locations of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPositionParams] + (#TextDocumentPositionParams) the response is of type {@link Declaration} + or a typed array of {@link DeclarationLink} or a Thenable that resolves + to such.""" + return await self.send_request("textDocument/declaration", params) + + async def selection_range( + self, params: lsp_types.SelectionRangeParams + ) -> Union[List["lsp_types.SelectionRange"], None]: + """A request to provide selection ranges in a document. The request's + parameter is of type {@link SelectionRangeParams}, the + response is of type {@link SelectionRange SelectionRange[]} or a Thenable + that resolves to such.""" + return await self.send_request("textDocument/selectionRange", params) + + async def prepare_call_hierarchy( + self, params: lsp_types.CallHierarchyPrepareParams + ) -> Union[List["lsp_types.CallHierarchyItem"], None]: + """A request to result a `CallHierarchyItem` in a document at a given position. + Can be used as an input to an incoming or outgoing call hierarchy. + + @since 3.16.0""" + return await self.send_request("textDocument/prepareCallHierarchy", params) + + async def incoming_calls( + self, params: lsp_types.CallHierarchyIncomingCallsParams + ) -> Union[List["lsp_types.CallHierarchyIncomingCall"], None]: + """A request to resolve the incoming calls for a given `CallHierarchyItem`. + + @since 3.16.0""" + return await self.send_request("callHierarchy/incomingCalls", params) + + async def outgoing_calls( + self, params: lsp_types.CallHierarchyOutgoingCallsParams + ) -> Union[List["lsp_types.CallHierarchyOutgoingCall"], None]: + """A request to resolve the outgoing calls for a given `CallHierarchyItem`. + + @since 3.16.0""" + return await self.send_request("callHierarchy/outgoingCalls", params) + + async def semantic_tokens_full( + self, params: lsp_types.SemanticTokensParams + ) -> Union["lsp_types.SemanticTokens", None]: + """@since 3.16.0""" + return await self.send_request("textDocument/semanticTokens/full", params) + + async def semantic_tokens_delta( + self, params: lsp_types.SemanticTokensDeltaParams + ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]: + """@since 3.16.0""" + return await self.send_request("textDocument/semanticTokens/full/delta", params) + + async def semantic_tokens_range( + self, params: lsp_types.SemanticTokensRangeParams + ) -> Union["lsp_types.SemanticTokens", None]: + """@since 3.16.0""" + return await self.send_request("textDocument/semanticTokens/range", params) + + async def linked_editing_range( + self, params: lsp_types.LinkedEditingRangeParams + ) -> Union["lsp_types.LinkedEditingRanges", None]: + """A request to provide ranges that can be edited together. + + @since 3.16.0""" + return await self.send_request("textDocument/linkedEditingRange", params) + + async def will_create_files( + self, params: lsp_types.CreateFilesParams + ) -> Union["lsp_types.WorkspaceEdit", None]: + """The will create files request is sent from the client to the server before files are actually + created as long as the creation is triggered from within the client. + + @since 3.16.0""" + return await self.send_request("workspace/willCreateFiles", params) + + async def will_rename_files( + self, params: lsp_types.RenameFilesParams + ) -> Union["lsp_types.WorkspaceEdit", None]: + """The will rename files request is sent from the client to the server before files are actually + renamed as long as the rename is triggered from within the client. + + @since 3.16.0""" + return await self.send_request("workspace/willRenameFiles", params) + + async def will_delete_files( + self, params: lsp_types.DeleteFilesParams + ) -> Union["lsp_types.WorkspaceEdit", None]: + """The did delete files notification is sent from the client to the server when + files were deleted from within the client. + + @since 3.16.0""" + return await self.send_request("workspace/willDeleteFiles", params) + + async def moniker( + self, params: lsp_types.MonikerParams + ) -> Union[List["lsp_types.Moniker"], None]: + """A request to get the moniker of a symbol at a given text document position. + The request parameter is of type {@link TextDocumentPositionParams}. + The response is of type {@link Moniker Moniker[]} or `null`.""" + return await self.send_request("textDocument/moniker", params) + + async def prepare_type_hierarchy( + self, params: lsp_types.TypeHierarchyPrepareParams + ) -> Union[List["lsp_types.TypeHierarchyItem"], None]: + """A request to result a `TypeHierarchyItem` in a document at a given position. + Can be used as an input to a subtypes or supertypes type hierarchy. + + @since 3.17.0""" + return await self.send_request("textDocument/prepareTypeHierarchy", params) + + async def type_hierarchy_supertypes( + self, params: lsp_types.TypeHierarchySupertypesParams + ) -> Union[List["lsp_types.TypeHierarchyItem"], None]: + """A request to resolve the supertypes for a given `TypeHierarchyItem`. + + @since 3.17.0""" + return await self.send_request("typeHierarchy/supertypes", params) + + async def type_hierarchy_subtypes( + self, params: lsp_types.TypeHierarchySubtypesParams + ) -> Union[List["lsp_types.TypeHierarchyItem"], None]: + """A request to resolve the subtypes for a given `TypeHierarchyItem`. + + @since 3.17.0""" + return await self.send_request("typeHierarchy/subtypes", params) + + async def inline_value( + self, params: lsp_types.InlineValueParams + ) -> Union[List["lsp_types.InlineValue"], None]: + """A request to provide inline values in a document. The request's parameter is of + type {@link InlineValueParams}, the response is of type + {@link InlineValue InlineValue[]} or a Thenable that resolves to such. + + @since 3.17.0""" + return await self.send_request("textDocument/inlineValue", params) + + async def inlay_hint( + self, params: lsp_types.InlayHintParams + ) -> Union[List["lsp_types.InlayHint"], None]: + """A request to provide inlay hints in a document. The request's parameter is of + type {@link InlayHintsParams}, the response is of type + {@link InlayHint InlayHint[]} or a Thenable that resolves to such. + + @since 3.17.0""" + return await self.send_request("textDocument/inlayHint", params) + + async def resolve_inlay_hint( + self, params: lsp_types.InlayHint + ) -> "lsp_types.InlayHint": + """A request to resolve additional properties for an inlay hint. + The request's parameter is of type {@link InlayHint}, the response is + of type {@link InlayHint} or a Thenable that resolves to such. + + @since 3.17.0""" + return await self.send_request("inlayHint/resolve", params) + + async def text_document_diagnostic( + self, params: lsp_types.DocumentDiagnosticParams + ) -> "lsp_types.DocumentDiagnosticReport": + """The document diagnostic request definition. + + @since 3.17.0""" + return await self.send_request("textDocument/diagnostic", params) + + async def workspace_diagnostic( + self, params: lsp_types.WorkspaceDiagnosticParams + ) -> "lsp_types.WorkspaceDiagnosticReport": + """The workspace diagnostic request definition. + + @since 3.17.0""" + return await self.send_request("workspace/diagnostic", params) + + async def initialize( + self, params: lsp_types.InitializeParams + ) -> "lsp_types.InitializeResult": + """The initialize request is sent from the client to the server. + It is sent once as the request after starting up the server. + The requests parameter is of type {@link InitializeParams} + the response if of type {@link InitializeResult} of a Thenable that + resolves to such.""" + return await self.send_request("initialize", params) + + async def shutdown(self) -> None: + """A shutdown request is sent from the client to the server. + It is sent once when the client decides to shutdown the + server. The only notification that is sent after a shutdown request + is the exit event.""" + return await self.send_request("shutdown") + + async def will_save_wait_until( + self, params: lsp_types.WillSaveTextDocumentParams + ) -> Union[List["lsp_types.TextEdit"], None]: + """A document will save request is sent from the client to the server before + the document is actually saved. The request can return an array of TextEdits + which will be applied to the text document before it is saved. Please note that + clients might drop results if computing the text edits took too long or if a + server constantly fails on this request. This is done to keep the save fast and + reliable.""" + return await self.send_request("textDocument/willSaveWaitUntil", params) + + async def completion( + self, params: lsp_types.CompletionParams + ) -> Union[List["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]: + """Request to request completion at a given text document position. The request's + parameter is of type {@link TextDocumentPosition} the response + is of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} + or a Thenable that resolves to such. + + The request can delay the computation of the {@link CompletionItem.detail `detail`} + and {@link CompletionItem.documentation `documentation`} properties to the `completionItem/resolve` + request. However, properties that are needed for the initial sorting and filtering, like `sortText`, + `filterText`, `insertText`, and `textEdit`, must not be changed during resolve. + """ + return await self.send_request("textDocument/completion", params) + + async def resolve_completion_item( + self, params: lsp_types.CompletionItem + ) -> "lsp_types.CompletionItem": + """Request to resolve additional information for a given completion item.The request's + parameter is of type {@link CompletionItem} the response + is of type {@link CompletionItem} or a Thenable that resolves to such.""" + return await self.send_request("completionItem/resolve", params) + + async def hover( + self, params: lsp_types.HoverParams + ) -> Union["lsp_types.Hover", None]: + """Request to request hover information at a given text document position. The request's + parameter is of type {@link TextDocumentPosition} the response is of + type {@link Hover} or a Thenable that resolves to such.""" + return await self.send_request("textDocument/hover", params) + + async def signature_help( + self, params: lsp_types.SignatureHelpParams + ) -> Union["lsp_types.SignatureHelp", None]: + return await self.send_request("textDocument/signatureHelp", params) + + async def definition( + self, params: lsp_types.DefinitionParams + ) -> Union["lsp_types.Definition", List["lsp_types.LocationLink"], None]: + """A request to resolve the definition location of a symbol at a given text + document position. The request's parameter is of type [TextDocumentPosition] + (#TextDocumentPosition) the response is of either type {@link Definition} + or a typed array of {@link DefinitionLink} or a Thenable that resolves + to such.""" + return await self.send_request("textDocument/definition", params) + + async def references( + self, params: lsp_types.ReferenceParams + ) -> Union[List["lsp_types.Location"], None]: + """A request to resolve project-wide references for the symbol denoted + by the given text document position. The request's parameter is of + type {@link ReferenceParams} the response is of type + {@link Location Location[]} or a Thenable that resolves to such.""" + return await self.send_request("textDocument/references", params) + + async def document_highlight( + self, params: lsp_types.DocumentHighlightParams + ) -> Union[List["lsp_types.DocumentHighlight"], None]: + """Request to resolve a {@link DocumentHighlight} for a given + text document position. The request's parameter is of type [TextDocumentPosition] + (#TextDocumentPosition) the request response is of type [DocumentHighlight[]] + (#DocumentHighlight) or a Thenable that resolves to such.""" + return await self.send_request("textDocument/documentHighlight", params) + + async def document_symbol( + self, params: lsp_types.DocumentSymbolParams + ) -> Union[ + List["lsp_types.SymbolInformation"], List["lsp_types.DocumentSymbol"], None + ]: + """A request to list all symbols found in a given text document. The request's + parameter is of type {@link TextDocumentIdentifier} the + response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable + that resolves to such.""" + return await self.send_request("textDocument/documentSymbol", params) + + async def code_action( + self, params: lsp_types.CodeActionParams + ) -> Union[List[Union["lsp_types.Command", "lsp_types.CodeAction"]], None]: + """A request to provide commands for the given text document and range.""" + return await self.send_request("textDocument/codeAction", params) + + async def resolve_code_action( + self, params: lsp_types.CodeAction + ) -> "lsp_types.CodeAction": + """Request to resolve additional information for a given code action.The request's + parameter is of type {@link CodeAction} the response + is of type {@link CodeAction} or a Thenable that resolves to such.""" + return await self.send_request("codeAction/resolve", params) + + async def workspace_symbol( + self, params: lsp_types.WorkspaceSymbolParams + ) -> Union[ + List["lsp_types.SymbolInformation"], List["lsp_types.WorkspaceSymbol"], None + ]: + """A request to list project-wide symbols matching the query string given + by the {@link WorkspaceSymbolParams}. The response is + of type {@link SymbolInformation SymbolInformation[]} or a Thenable that + resolves to such. + + @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients + need to advertise support for WorkspaceSymbols via the client capability + `workspace.symbol.resolveSupport`. + """ + return await self.send_request("workspace/symbol", params) + + async def resolve_workspace_symbol( + self, params: lsp_types.WorkspaceSymbol + ) -> "lsp_types.WorkspaceSymbol": + """A request to resolve the range inside the workspace + symbol's location. + + @since 3.17.0""" + return await self.send_request("workspaceSymbol/resolve", params) + + async def code_lens( + self, params: lsp_types.CodeLensParams + ) -> Union[List["lsp_types.CodeLens"], None]: + """A request to provide code lens for the given text document.""" + return await self.send_request("textDocument/codeLens", params) + + async def resolve_code_lens( + self, params: lsp_types.CodeLens + ) -> "lsp_types.CodeLens": + """A request to resolve a command for a given code lens.""" + return await self.send_request("codeLens/resolve", params) + + async def document_link( + self, params: lsp_types.DocumentLinkParams + ) -> Union[List["lsp_types.DocumentLink"], None]: + """A request to provide document links""" + return await self.send_request("textDocument/documentLink", params) + + async def resolve_document_link( + self, params: lsp_types.DocumentLink + ) -> "lsp_types.DocumentLink": + """Request to resolve additional information for a given document link. The request's + parameter is of type {@link DocumentLink} the response + is of type {@link DocumentLink} or a Thenable that resolves to such.""" + return await self.send_request("documentLink/resolve", params) + + async def formatting( + self, params: lsp_types.DocumentFormattingParams + ) -> Union[List["lsp_types.TextEdit"], None]: + """A request to to format a whole document.""" + return await self.send_request("textDocument/formatting", params) + + async def range_formatting( + self, params: lsp_types.DocumentRangeFormattingParams + ) -> Union[List["lsp_types.TextEdit"], None]: + """A request to to format a range in a document.""" + return await self.send_request("textDocument/rangeFormatting", params) + + async def on_type_formatting( + self, params: lsp_types.DocumentOnTypeFormattingParams + ) -> Union[List["lsp_types.TextEdit"], None]: + """A request to format a document on type.""" + return await self.send_request("textDocument/onTypeFormatting", params) + + async def rename( + self, params: lsp_types.RenameParams + ) -> Union["lsp_types.WorkspaceEdit", None]: + """A request to rename a symbol.""" + return await self.send_request("textDocument/rename", params) + + async def prepare_rename( + self, params: lsp_types.PrepareRenameParams + ) -> Union["lsp_types.PrepareRenameResult", None]: + """A request to test and perform the setup necessary for a rename. + + @since 3.16 - support for default behavior""" + return await self.send_request("textDocument/prepareRename", params) + + async def execute_command( + self, params: lsp_types.ExecuteCommandParams + ) -> Union["lsp_types.LSPAny", None]: + """A request send from the client to the server to execute a command. The request might return + a workspace edit which the client will apply to the workspace.""" + return await self.send_request("workspace/executeCommand", params) From de466ef6a93e5dfa17dc1538dfbaad465ae74bc3 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 13:05:46 +0200 Subject: [PATCH 02/30] Initial implementation of fully synchronous language server (SolidLanguageServer) with working PyRight implementation --- src/serena/agent.py | 22 ++- src/solidlsp/ls.py | 263 +++++++++++++++++------------------ src/solidlsp/ls_handler.py | 268 +++++++++++++++++------------------- src/solidlsp/lsp_request.py | 206 +++++++++++++-------------- 4 files changed, 374 insertions(+), 385 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 0f7aefd2..6ced373f 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -49,6 +49,7 @@ from serena.util.general import load_yaml, save_yaml from serena.util.inspection import determine_programming_language_composition, iter_subclasses from serena.util.shell import execute_shell_command from serena.util.thread import ExecutionResult, execute_with_timeout +from solidlsp.ls import SolidLanguageServer if TYPE_CHECKING: from serena.gui_log_viewer import GuiLogViewerHandler @@ -666,12 +667,21 @@ def create_ls_for_project( ) ls_logger = MultilspyLogger(log_level=log_level) log.info(f"Creating language server instance for {project_instance.project_root}.") - return SyncLanguageServer.create( - multilspy_config, - ls_logger, - project_instance.project_root, - timeout=ls_timeout, - ) + use_solid_ls = True + if use_solid_ls: + return SolidLanguageServer.create( + multilspy_config, + ls_logger, + project_instance.project_root, + timeout=ls_timeout, + ) + else: + return SyncLanguageServer.create( + multilspy_config, + ls_logger, + project_instance.project_root, + timeout=ls_timeout, + ) @click.command() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6d84cad9..4d37ca7b 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -24,7 +24,6 @@ from multilspy.lsp_protocol_handler.lsp_constants import LSPConstants from multilspy.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink, SymbolKind, InitializeParams from multilspy.lsp_protocol_handler.server import ( Error, - LanguageServerHandler, ProcessLaunchInfo, StringDict, ) @@ -33,9 +32,10 @@ from multilspy.multilspy_exceptions import MultilspyException from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_utils import FileUtils, PathUtils, TextUtils from serena.text_utils import MatchedConsecutiveLines, search_files +from solidlsp.ls_handler import SolidLanguageServerHandler -class LanguageServer: +class SolidLanguageServer: """ The LanguageServer class provides a language agnostic interface to the Language Server Protocol. It is used to communicate with Language Servers of different programming languages. @@ -50,7 +50,7 @@ class LanguageServer: return dirname.startswith('.') @classmethod - def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str) -> "LanguageServer": + def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str, timeout) -> "SolidLanguageServer": """ Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. @@ -67,7 +67,7 @@ class LanguageServer: PyrightServer, ) - return PyrightServer(config, logger, repository_root_path) + return SolidPyrightServer(config, logger, repository_root_path) # It used to be jedi, but pyright is a bit faster, and also more actively maintained # Keeping the previous code for reference # from multilspy.language_servers.jedi_language_server.jedi_server import ( @@ -147,11 +147,6 @@ class LanguageServer: The command must pass appropriate flags to the binary, so that it runs in the stdio mode, as opposed to HTTP, TCP modes supported by some language servers. """ - if type(self) == LanguageServer: - raise MultilspyException( - "LanguageServer is an abstract class and cannot be instantiated directly. Use LanguageServer.create method instead." - ) - self.logger = logger self.repository_root_path: str = repository_root_path self.logger.log(f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}", logging.DEBUG) @@ -168,7 +163,7 @@ class LanguageServer: self.load_cache() self.server_started = False - self.completions_available = asyncio.Event() + self.completions_available = threading.Event() if config.trace_lsp_communication: def logging_fn(source: str, target: str, msg: StringDict | str): self.logger.log(f"LSP: {source} -> {target}: {str(msg)[:90]}...", self.logger.logger.level) @@ -179,7 +174,7 @@ class LanguageServer: # cmd is obtained from the child classes, which provide the language specific command to start the language server # LanguageServerHandler provides the functionality to start the language server and communicate with it self.logger.log(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}", logging.DEBUG) - self.server = LanguageServerHandler( + self.server = SolidLanguageServerHandler( process_launch_info, logger=logging_fn, start_independent_lsp_process=config.start_independent_lsp_process, @@ -201,6 +196,8 @@ class LanguageServer: processed_patterns ) + self._server_context = None + def get_ignore_spec(self) -> pathspec.PathSpec: """Returns the pathspec matcher for the paths that were configured to be ignored through the multilspy config. @@ -262,7 +259,7 @@ class LanguageServer: return False - async def _shutdown(self, timeout: float = 5.0): + def _shutdown(self, timeout: float = 5.0): """ A robust shutdown process designed to terminate cleanly on all platforms, including Windows, by explicitly closing all I/O pipes. @@ -276,27 +273,19 @@ class LanguageServer: reader_tasks = list(self.server.tasks.values()) # --- Main Shutdown Logic --- + # Stage 1: Graceful Termination Request + # Send LSP shutdown and close stdin to signal no more input. try: - # Stage 1: Graceful Termination Request - # Send LSP shutdown and close stdin to signal no more input. - try: - await asyncio.wait_for(self.server.shutdown(), timeout=2.0) - if process.stdin and not process.stdin.is_closing(): - process.stdin.close() - except Exception: - pass # Ignore errors here, we are proceeding to terminate anyway. + self.server.shutdown() + if process.stdin and not process.stdin.is_closing(): + process.stdin.close() + except Exception: + pass # Ignore errors here, we are proceeding to terminate anyway. - # Stage 2: Terminate and Concurrently Drain stdout/stderr - process.terminate() - - # Wait for the process to exit AND for the output pipes to be drained. - # The reader tasks will exit when they hit EOF. - await asyncio.wait_for( - asyncio.gather(process.wait(), *reader_tasks), - timeout=timeout - 2.0 - ) - self.logger.log("Process terminated and output pipes drained.", logging.INFO) + # Stage 2: Terminate and Concurrently Drain stdout/stderr + process.terminate() + """ except asyncio.TimeoutError: # Stage 3: Forceful Kill self.logger.log("Graceful termination failed. Forcefully killing process...", logging.WARNING) @@ -307,7 +296,6 @@ class LanguageServer: await process.wait() except Exception as e: self.logger.log(f"Error during forceful kill: {e}", logging.ERROR) - except Exception as e: self.logger.log(f"An unexpected error occurred during shutdown logic: {e}", logging.ERROR) @@ -334,18 +322,16 @@ class LanguageServer: # 3. Null out the process object in the handler. self.server.process = None self.logger.log("Shutdown sequence fully finished.", logging.DEBUG) + """ - @asynccontextmanager - async def start_server(self) -> AsyncIterator["LanguageServer"]: - """ - Starts the Language Server and yields the LanguageServer instance. - """ + @contextmanager + def start_server(self) -> Iterator["SolidLanguageServer"]: + self.start() + yield self + self._shutdown() + + def _start_server(self) -> None: self.server_started = True - try: - yield self - finally: - self.server_started = False - await self._shutdown() @contextmanager def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]: @@ -404,7 +390,7 @@ class LanguageServer: self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str ) -> multilspy_types.Position: """ - Insert text at the given line and column in the given file and return + Insert text at the given line and column in the given file and return the updated cursor position after inserting the text. :param relative_file_path: The relative path of the file to open. @@ -486,10 +472,10 @@ class LanguageServer: ) return deleted_text - async def _send_definition_request(self, definition_params: DefinitionParams) -> Union[Definition, List[LocationLink], None]: - return await self.server.send.definition(definition_params) + def _send_definition_request(self, definition_params: DefinitionParams) -> Union[Definition, List[LocationLink], None]: + return self.server.send.definition(definition_params) - async def request_definition( + def request_definition( self, relative_file_path: str, line: int, column: int ) -> List[multilspy_types.Location]: """ @@ -523,7 +509,7 @@ class LanguageServer: LSPConstants.CHARACTER: column, }, }) - response = await self._send_definition_request(definition_params) + response = self._send_definition_request(definition_params) ret: List[multilspy_types.Location] = [] if isinstance(response, list): @@ -573,8 +559,8 @@ class LanguageServer: return ret # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses - async def _send_references_request(self, relative_file_path: str, line: int, column: int) -> List[lsp_types.Location] | None: - return await self.server.send.references( + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> List[lsp_types.Location] | None: + return self.server.send.references( { "textDocument": {"uri": PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))}, "position": {"line": line, "character": column}, @@ -582,7 +568,7 @@ class LanguageServer: } ) - async def request_references( + def request_references( self, relative_file_path: str, line: int, column: int ) -> List[multilspy_types.Location]: """ @@ -607,7 +593,7 @@ class LanguageServer: with self.open_file(relative_file_path): try: - response = await self._send_references_request(relative_file_path, line=line, column=column) + response = self._send_references_request(relative_file_path, line=line, column=column) except Exception as e: # Catch LSP internal error (-32603) and raise a more informative exception if isinstance(e, Error) and getattr(e, 'code', None) == -32603: @@ -640,7 +626,7 @@ class LanguageServer: return ret - async def request_references_with_content( + def request_references_with_content( self, relative_file_path: str, line: int, column: int, context_lines_before: int = 0, context_lines_after: int = 0 ) -> List[MatchedConsecutiveLines]: """ @@ -654,7 +640,7 @@ class LanguageServer: :return: A list of MatchedConsecutiveLines objects, one for each reference. """ - references = await self.request_references(relative_file_path, line, column) + references = self.request_references(relative_file_path, line, column) return [self.retrieve_content_around_line(ref["relativePath"], ref["range"]["start"]["line"], context_lines_before, context_lines_after) for ref in references] def retrieve_full_file_content(self, relative_file_path: str) -> str: @@ -679,8 +665,7 @@ class LanguageServer: file_contents = file_data.contents return MatchedConsecutiveLines.from_file_contents(file_contents, line=line, context_lines_before=context_lines_before, context_lines_after=context_lines_after, source_file_path=relative_file_path) - - async def request_completions( + def request_completions( self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False ) -> List[multilspy_types.CompletionItem]: """ @@ -706,10 +691,10 @@ class LanguageServer: num_retries = 0 while response is None or (response["isIncomplete"] and num_retries < 30): - await self.completions_available.wait() + self.completions_available.wait() response: Union[ List[LSPTypes.CompletionItem], LSPTypes.CompletionList, None - ] = await self.server.send.completion(completion_params) + ] = self.server.send.completion(completion_params) if isinstance(response, list): response = {"items": response, "isIncomplete": False} num_retries += 1 @@ -774,7 +759,7 @@ class LanguageServer: for json_repr in set([json.dumps(item, sort_keys=True) for item in completions_list]) ] - async def request_document_symbols(self, relative_file_path: str, include_body: bool = False) -> Tuple[List[multilspy_types.UnifiedSymbolInformation], List[multilspy_types.UnifiedSymbolInformation]]: + def request_document_symbols(self, relative_file_path: str, include_body: bool = False) -> Tuple[List[multilspy_types.UnifiedSymbolInformation], List[multilspy_types.UnifiedSymbolInformation]]: """ Raise a [textDocument/documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) request to the Language Server to find symbols in the given file. Wait for the response and return the result. @@ -805,7 +790,7 @@ class LanguageServer: self.logger.log(f"No cache hit for symbols with {include_body=} in {relative_file_path}", logging.DEBUG) self.logger.log(f"Requesting document symbols for {relative_file_path} from the Language Server", logging.DEBUG) - response = await self.server.send.document_symbol( + response = self.server.send.document_symbol( { "textDocument": { "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() @@ -892,9 +877,9 @@ class LanguageServer: self._cache_has_changed = True return result - async def request_full_symbol_tree(self, within_relative_path: str | None = None, include_body: bool = False) -> List[multilspy_types.UnifiedSymbolInformation]: + def request_full_symbol_tree(self, within_relative_path: str | None = None, include_body: bool = False) -> List[multilspy_types.UnifiedSymbolInformation]: """ - Will go through all files in the project or within a relative path and build a tree of symbols. + Will go through all files in the project or within a relative path and build a tree of symbols. Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search. For each file, a symbol of kind File (2) will be created. For directories, a symbol of kind Package (4) will be created. @@ -921,11 +906,11 @@ class LanguageServer: self.logger.log(f"You passed a file explicitly, but it is ignored. This is probably an error. File: {within_relative_path}", logging.ERROR) return [] else: - _, root_nodes = await self.request_document_symbols(within_relative_path, include_body=include_body) + _, root_nodes = self.request_document_symbols(within_relative_path, include_body=include_body) return root_nodes # Helper function to recursively process directories - async def process_directory(rel_dir_path: str) -> List[multilspy_types.UnifiedSymbolInformation]: + def process_directory(rel_dir_path: str) -> List[multilspy_types.UnifiedSymbolInformation]: abs_dir_path = self.repository_root_path if rel_dir_path == "." else os.path.join(self.repository_root_path, rel_dir_path) abs_dir_path = os.path.realpath(abs_dir_path) @@ -961,13 +946,13 @@ class LanguageServer: continue if os.path.isdir(contained_dir_or_file_abs_path): - child_symbols = await process_directory(contained_dir_or_file_rel_path) + child_symbols = process_directory(contained_dir_or_file_rel_path) package_symbol["children"].extend(child_symbols) for child in child_symbols: child["parent"] = package_symbol elif os.path.isfile(contained_dir_or_file_abs_path): - _, file_root_nodes = await self.request_document_symbols(contained_dir_or_file_rel_path, include_body=include_body) + _, file_root_nodes = self.request_document_symbols(contained_dir_or_file_rel_path, include_body=include_body) # Create file symbol, link with children file_rel_path = str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)) @@ -1013,7 +998,7 @@ class LanguageServer: # Start from the root or the specified directory start_rel_path = within_relative_path or "." - return await process_directory(start_rel_path) + return process_directory(start_rel_path) @staticmethod def _get_range_from_file_content(file_content: str) -> multilspy_types.Range: @@ -1028,14 +1013,14 @@ class LanguageServer: end=multilspy_types.Position(line=end_line, character=end_column) ) - async def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[tuple[str, multilspy_types.SymbolKind, int, int]]]: + def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[tuple[str, multilspy_types.SymbolKind, int, int]]]: """ An overview of the given directory. Maps relative paths of all contained files to info about top-level symbols in the file (name, kind, line, column). """ - symbol_tree = await self.request_full_symbol_tree(relative_dir_path) + symbol_tree = self.request_full_symbol_tree(relative_dir_path) # Initialize result dictionary result: dict[str, list[tuple[str, multilspy_types.SymbolKind, int, int]]] = defaultdict(list) @@ -1062,12 +1047,12 @@ class LanguageServer: process_symbol(root) return result - async def request_document_overview(self, relative_file_path: str) -> list[tuple[str, multilspy_types.SymbolKind, int, int]]: + def request_document_overview(self, relative_file_path: str) -> list[tuple[str, multilspy_types.SymbolKind, int, int]]: """ An overview of the given file. Returns the list of tuples (name, kind, line, column) of all top-level symbols in the file. """ - _, document_roots = await self.request_document_symbols(relative_file_path) + _, document_roots = self.request_document_symbols(relative_file_path) result = [] for root in document_roots: try: @@ -1083,7 +1068,7 @@ class LanguageServer: ) from e return result - async def request_overview(self, within_relative_path: str) -> dict[str, list[tuple[str, multilspy_types.SymbolKind, int, int]]]: + def request_overview(self, within_relative_path: str) -> dict[str, list[tuple[str, multilspy_types.SymbolKind, int, int]]]: """ An overview of all symbols in the given file or directory. @@ -1095,12 +1080,12 @@ class LanguageServer: raise FileNotFoundError(f"File or directory not found: {abs_path}") if abs_path.is_file(): - symbols_overview = await self.request_document_overview(within_relative_path) + symbols_overview = self.request_document_overview(within_relative_path) return {within_relative_path: symbols_overview} else: - return await self.request_dir_overview(within_relative_path) + return self.request_dir_overview(within_relative_path) - async def request_hover(self, relative_file_path: str, line: int, column: int) -> Union[multilspy_types.Hover, None]: + def request_hover(self, relative_file_path: str, line: int, column: int) -> Union[multilspy_types.Hover, None]: """ Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server to find the hover information at the given line and column in the given file. Wait for the response and return the result. @@ -1112,7 +1097,7 @@ class LanguageServer: :return None """ with self.open_file(relative_file_path): - response = await self.server.send.hover( + response = self.server.send.hover( { "textDocument": { "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() @@ -1154,7 +1139,7 @@ class LanguageServer: symbol_body = symbol_body[symbol_start_column:] return symbol_body - async def request_parsed_files(self) -> list[str]: + def request_parsed_files(self) -> list[str]: """Retrieves relative paths of all files analyzed by the Language Server.""" if not self.server_started: self.logger.log( @@ -1171,7 +1156,7 @@ class LanguageServer: rel_file_paths.append(rel_file_path) return rel_file_paths - async def search_files_for_pattern( + def search_files_for_pattern( self, pattern: re.Pattern | str, context_lines_before: int = 0, @@ -1192,7 +1177,7 @@ class LanguageServer: if isinstance(pattern, str): pattern = re.compile(pattern) - relative_file_paths = await self.request_parsed_files() + relative_file_paths = self.request_parsed_files() return search_files( relative_file_paths, pattern, @@ -1203,7 +1188,7 @@ class LanguageServer: paths_exclude_glob=paths_exclude_glob ) - async def request_referencing_symbols( + def request_referencing_symbols( self, relative_file_path: str, line: int, @@ -1239,7 +1224,7 @@ class LanguageServer: raise MultilspyException("Language Server not started") # First, get all references to the symbol - references = await self.request_references(relative_file_path, line, column) + references = self.request_references(relative_file_path, line, column) if not references: return [] @@ -1253,7 +1238,7 @@ class LanguageServer: with self.open_file(ref_path) as file_data: # Get the containing symbol for this reference - containing_symbol = await self.request_containing_symbol( + containing_symbol = self.request_containing_symbol( ref_path, ref_line, ref_col, include_body=include_body ) if containing_symbol is None: @@ -1273,7 +1258,7 @@ class LanguageServer: ref_text = file_data.contents.split("\n")[ref_line] if "." in ref_text: containing_symbol_name = ref_text.split(".")[0] - all_symbols, _ = await self.request_document_symbols(ref_path) + all_symbols, _ = self.request_document_symbols(ref_path) for symbol in all_symbols: if symbol["name"] == containing_symbol_name and symbol["kind"] == multilspy_types.SymbolKind.Variable: containing_symbol = copy(symbol) @@ -1350,7 +1335,7 @@ class LanguageServer: return result - async def request_containing_symbol( + def request_containing_symbol( self, relative_file_path: str, line: int, @@ -1397,7 +1382,7 @@ class LanguageServer: ) return None - symbols, _ = await self.request_document_symbols(relative_file_path) + symbols, _ = self.request_document_symbols(relative_file_path) # make jedi and pyright api compatible # the former has no location, the later has no range @@ -1470,7 +1455,7 @@ class LanguageServer: else: return None - async def request_container_of_symbol(self, symbol: multilspy_types.UnifiedSymbolInformation, include_body: bool = False) -> multilspy_types.UnifiedSymbolInformation | None: + def request_container_of_symbol(self, symbol: multilspy_types.UnifiedSymbolInformation, include_body: bool = False) -> multilspy_types.UnifiedSymbolInformation | None: """ Finds the container of the given symbol if there is one. If the parent attribute is present, the parent is returned without further searching. @@ -1482,7 +1467,7 @@ class LanguageServer: if "parent" in symbol: return symbol["parent"] assert "location" in symbol, f"Symbol {symbol} has no location and no parent attribute" - return await self.request_containing_symbol( + return self.request_containing_symbol( symbol["location"]["relativePath"], symbol["location"]["range"]["start"]["line"], symbol["location"]["range"]["start"]["character"], @@ -1490,7 +1475,7 @@ class LanguageServer: include_body=include_body, ) - async def request_defining_symbol( + def request_defining_symbol( self, relative_file_path: str, line: int, @@ -1517,7 +1502,7 @@ class LanguageServer: raise MultilspyException("Language Server not started") # Get the definition location(s) - definitions = await self.request_definition(relative_file_path, line, column) + definitions = self.request_definition(relative_file_path, line, column) if not definitions: return None @@ -1528,7 +1513,7 @@ class LanguageServer: def_col = definition["range"]["start"]["character"] # Find the symbol at or containing this location - defining_symbol = await self.request_containing_symbol( + defining_symbol = self.request_containing_symbol( def_path, def_line, def_col, strict=False, include_body=include_body ) @@ -1541,19 +1526,19 @@ class LanguageServer: """ return Path(self.repository_root_path) / ".serena" / "cache" / self.language_id / "document_symbols_cache_v20-05-25.pkl" - async def index_repository(self, progress_bar: bool = True, save_after_n_files: int = 10) -> None: + def index_repository(self, progress_bar: bool = True, save_after_n_files: int = 10) -> None: """Will go through the entire repository and "index" all files, meaning save their symbols to the cache. :param progress_bar: Whether to show a progress bar while indexing the repository. :param save_after_n_files: How many files to process before saving a checkpoint of the cache. """ - parsed_files = await self.request_parsed_files() + parsed_files = self.request_parsed_files() files_processed = 0 pbar = tqdm.tqdm(parsed_files, disable=not progress_bar) for relative_file_path in pbar: pbar.set_description(f"Indexing ({os.path.basename(relative_file_path)})") - await self.request_document_symbols(relative_file_path, include_body=False) - await self.request_document_symbols(relative_file_path, include_body=True) + self.request_document_symbols(relative_file_path, include_body=False) + self.request_document_symbols(relative_file_path, include_body=True) files_processed += 1 if files_processed % save_after_n_files == 0: self.save_cache() @@ -1596,8 +1581,7 @@ class LanguageServer: logging.ERROR, ) - - async def request_workspace_symbol(self, query: str) -> Union[List[multilspy_types.UnifiedSymbolInformation], None]: + def request_workspace_symbol(self, query: str) -> Union[List[multilspy_types.UnifiedSymbolInformation], None]: """ Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server to find symbols across the whole workspace. Wait for the response and return the result. @@ -1606,7 +1590,7 @@ class LanguageServer: :return: A list of matching symbols """ - response = await self.server.send.workspace_symbol({"query": query}) + response = self.server.send.workspace_symbol({"query": query}) if response is None: return None @@ -1624,8 +1608,25 @@ class LanguageServer: return ret + def start(self) -> "SolidLanguageServer": + """ + Starts the language server process and connects to it. Call shutdown when ready. -class PyrightServer(LanguageServer): + :return: self for method chaining + """ + self.logger.log(f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}", logging.INFO) + self._server_context = self._start_server() + return self + + @property + def language_server(self) -> Self: + return self + + def is_running(self) -> bool: + return self.server.is_running() + + +class SolidPyrightServer(SolidLanguageServer): """ Provides Python specific instantiation of the LanguageServer class using Pyright. Contains various configurations and settings specific to Python. @@ -1646,7 +1647,7 @@ class PyrightServer(LanguageServer): ) # Event to signal when initial workspace analysis is complete - self.analysis_complete = asyncio.Event() + self.analysis_complete = threading.Event() self.found_source_files = False @override @@ -1753,8 +1754,7 @@ class PyrightServer(LanguageServer): return initialize_params - @asynccontextmanager - async def start_server(self) -> AsyncIterator["PyrightServer"]: + def _start_server(self): """ Starts the Pyright Language Server and waits for initial workspace analysis to complete. @@ -1772,13 +1772,13 @@ class PyrightServer(LanguageServer): ``` """ - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def window_log_message(msg): + def window_log_message(msg): """ Monitor Pyright's log messages to detect when initial analysis is complete. Pyright logs "Found X source files" when it finishes scanning the workspace. @@ -1794,7 +1794,7 @@ class PyrightServer(LanguageServer): self.analysis_complete.set() self.completions_available.set() - async def check_experimental_status(params): + def check_experimental_status(params): """ Also listen for experimental/serverStatus as a backup signal """ @@ -1814,38 +1814,35 @@ class PyrightServer(LanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - async with super().start_server(): - self.logger.log("Starting pyright-langserver server process", logging.INFO) - await self.server.start() + super()._start_server() + self.logger.log("Starting pyright-langserver server process", logging.INFO) + self.server.start() - # Send proper initialization parameters - initialize_params = self._get_initialize_params(self.repository_root_path) + # Send proper initialization parameters + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to pyright server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) + self.logger.log( + "Sending initialize request from LSP client to pyright server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) - # Verify that the server supports our required features - assert "textDocumentSync" in init_response["capabilities"] - assert "completionProvider" in init_response["capabilities"] - assert "definitionProvider" in init_response["capabilities"] + # Verify that the server supports our required features + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] - # Complete the initialization handshake - self.server.notify.initialized({}) + # Complete the initialization handshake + self.server.notify.initialized({}) - # Wait for Pyright to complete its initial workspace analysis - # This prevents zombie processes by ensuring background tasks finish - self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) - try: - await asyncio.wait_for(self.analysis_complete.wait(), timeout=1.0) - self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) - except asyncio.TimeoutError: - self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) - # Fallback: assume analysis is complete after timeout - self.analysis_complete.set() - self.completions_available.set() - - yield self + # Wait for Pyright to complete its initial workspace analysis + # This prevents zombie processes by ensuring background tasks finish + self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) + if self.analysis_complete.wait(timeout=5.0): + self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) + else: + self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) + # Fallback: assume analysis is complete after timeout + self.analysis_complete.set() + self.completions_available.set() diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_handler.py index 91af0177..b34571f9 100644 --- a/src/solidlsp/ls_handler.py +++ b/src/solidlsp/ls_handler.py @@ -1,25 +1,51 @@ import asyncio -import dataclasses import json import logging import os import subprocess import threading +import time +from dataclasses import dataclass +from queue import Queue +from typing import Any, Callable, Dict, Optional import psutil -from typing import Any, Callable, Dict, List, Optional, Union -from multilspy.lsp_protocol_handler.lsp_requests import LspNotification, LspRequest +from multilspy.lsp_protocol_handler.lsp_requests import LspNotification from multilspy.lsp_protocol_handler.lsp_types import ErrorCodes -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict, Request, content_length, ENCODING, Error, MessageType, \ +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict, content_length, ENCODING, Error, MessageType, \ make_notification, PayloadLike, make_response, make_error_response, make_request, create_message from multilspy.multilspy_exceptions import MultilspyException - +from solidlsp.lsp_request import SolidLspRequest log = logging.getLogger(__name__) -class LanguageServerHandler: +class Request: + + @dataclass + class Result: + payload: Optional[PayloadLike] = None + error: Optional[Error] = None + + def is_error(self) -> bool: + return self.error is not None + + def __init__(self) -> None: + self._result_queue = Queue() + + def on_result(self, params: PayloadLike) -> None: + self._result_queue.put(Request.Result(payload=params)) + + def on_error(self, err: Error) -> None: + self._result_queue.put(Request.Result(error=err)) + + def get_result(self) -> Result: + # TODO could add timeout + return self._result_queue.get() + + +class SolidLanguageServerHandler: """ This class provides the implementation of Python client for the Language Server Protocol. A class that launches the language server and communicates with it @@ -69,7 +95,7 @@ class LanguageServerHandler: logger: An optional function that takes two strings (source and destination) and a payload dictionary, and logs the communication between the client and the server. """ - self.send = LspRequest(self.send_request) + self.send = SolidLspRequest(self.send_request) self.notify = LspNotification(self.send_notification) self.process_launch_info = process_launch_info @@ -92,15 +118,13 @@ class LanguageServerHandler: self._response_handlers_lock = threading.Lock() self._tasks_lock = threading.Lock() - - def is_running(self) -> bool: """ Checks if the language server process is currently running. """ return self.process is not None and self.process.returncode is None - async def start(self) -> None: + def start(self) -> None: """ Starts the language server process and creates a task to continuously read from its stdout to handle communications from the server to the client @@ -109,73 +133,47 @@ class LanguageServerHandler: child_proc_env.update(self.process_launch_info.env) log.info("Starting language server process via command: %s", self.process_launch_info.cmd) - self.process = await asyncio.create_subprocess_shell( + self.process = subprocess.Popen( self.process_launch_info.cmd, - stdout=asyncio.subprocess.PIPE, - stdin=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, env=child_proc_env, cwd=self.process_launch_info.cwd, start_new_session=self.start_independent_lsp_process, + shell=True ) # Check if process terminated immediately if self.process.returncode is not None: log.error("Language server has already terminated/could not be started") # Process has already terminated - stderr_data = await self.process.stderr.read() + stderr_data = self.process.stderr.read() error_message = stderr_data.decode('utf-8', errors='replace') raise RuntimeError(f"Process terminated immediately with code {self.process.returncode}. Error: {error_message}") - self.loop = asyncio.get_event_loop() + # start threads to read stdout and stderr of the process + threading.Thread( + target=self.run_forever, + name="LSP stdout reader", + daemon=True, + ).start() + threading.Thread( + target=self.run_forever_stderr, + name="LSP stderr reader", + daemon=True, + ).start() - # Use lock to prevent race conditions on tasks and task_counter during startup - with self._tasks_lock: - self.tasks[self.task_counter] = self.loop.create_task(self.run_forever()) - self.task_counter += 1 - self.tasks[self.task_counter] = self.loop.create_task(self.run_forever_stderr()) - self.task_counter += 1 - - - - async def stop(self) -> None: + def stop(self) -> None: """ Sends the terminate signal to the language server process and waits for it to exit, with a timeout, killing it if necessary """ - # First cancel all tasks - await self._cancel_pending_tasks() - process = self.process self.process = None + if process: + self._cleanup_process(process) - if not process: - return - - # Clean up the process - await self._cleanup_process(process) - - async def _cancel_pending_tasks(self): - """Cancel all pending tasks and wait for them to complete or timeout.""" - pending_tasks = [] - - # Use lock to safely access tasks dictionary - with self._tasks_lock: - for task in self.tasks.values(): - if not task.done(): - task.cancel() - pending_tasks.append(task) - - if pending_tasks: - try: - await asyncio.wait_for(asyncio.gather(*pending_tasks, return_exceptions=True), timeout=5.0) - except (asyncio.TimeoutError, Exception): - pass - - # Clear tasks dictionary under lock - with self._tasks_lock: - self.tasks = {} - - async def _cleanup_process(self, process): + def _cleanup_process(self, process): """Clean up a process: close stdin, terminate/kill process, close stdout/stderr.""" # Close stdin first to prevent deadlocks # See: https://bugs.python.org/issue35539 @@ -183,7 +181,7 @@ class LanguageServerHandler: # Terminate/kill the process if it's still running if process.returncode is None: - await self._terminate_or_kill_process(process) + self._terminate_or_kill_process(process) # Close stdout and stderr pipes after process has exited # This is essential to prevent "I/O operation on closed pipe" errors and @@ -192,9 +190,6 @@ class LanguageServerHandler: self._safely_close_pipe(process.stdout) self._safely_close_pipe(process.stderr) - # Small delay to ensure OS has released file handles - await asyncio.sleep(0.5) - def _safely_close_pipe(self, pipe): """Safely close a pipe, ignoring any exceptions.""" if pipe: @@ -203,14 +198,16 @@ class LanguageServerHandler: except Exception: pass - async def _terminate_or_kill_process(self, process): + def _terminate_or_kill_process(self, process): """Try to terminate the process gracefully, then forcefully if necessary.""" # First try to terminate the process tree gracefully self._signal_process_tree(process, terminate=True) + # TODO + """ # Wait for the process to exit (with timeout) try: - await asyncio.wait_for(process.wait(), timeout=10) + asyncio.wait_for(process.wait(), timeout=10) except (asyncio.TimeoutError, Exception): # If termination failed, forcefully kill the process tree self._signal_process_tree(process, terminate=False) @@ -219,6 +216,7 @@ class LanguageServerHandler: await asyncio.wait_for(process.wait(), timeout=2) except Exception: pass + """ def _signal_process_tree(self, process, terminate=True): """Send signal (terminate or kill) to the process and all its children.""" @@ -252,23 +250,25 @@ class LanguageServerHandler: except Exception: pass - - async def shutdown(self) -> None: + def shutdown(self) -> None: """ Perform the shutdown sequence for the client, including sending the shutdown request to the server and notifying it of exit """ self._log("Sending shutdown request to server") - await self.send.shutdown() + self.send.shutdown() self._log("Received shutdown response from server") self._received_shutdown = True self._log("Sending exit notification to server") self.notify.exit() self._log("Sent exit notification to server") + # TODO + """ if self.process and self.process.stdout: self.process.stdout.set_exception(StopLoopException()) # This yields the control to the event loop to allow the exception to be handled # in the run_forever and run_forever_stderr methods await asyncio.sleep(0) + """ def _log(self, message: str | StringDict) -> None: """ @@ -277,14 +277,33 @@ class LanguageServerHandler: if self.logger is not None: self.logger("client", "logger", message) - async def run_forever(self) -> bool: + @staticmethod + def _read_bytes_from_process(process, stream, num_bytes): + """Read exactly num_bytes from process stdout""" + if process.poll() is not None: + # Process has terminated, check if we can still read + pass + + data = b'' + while len(data) < num_bytes: + chunk = stream.read(num_bytes - len(data)) + if not chunk: + if process.poll() is not None: + raise EOFError(f"Process terminated. Expected {num_bytes} bytes, got {len(data)}") + # Process still running but no data available yet + time.sleep(0.01) # Small delay + continue + data += chunk + return data + + def run_forever(self) -> bool: """ Continuously read from the language server process stdout and handle the messages invoking the registered response and notification handlers """ try: - while self.process and self.process.stdout and not self.process.stdout.at_eof(): - line = await self.process.stdout.readline() + while self.process and self.process.stdout and self.process.stdout.readable(): + line = self.process.stdout.readline() if not line: continue try: @@ -294,39 +313,35 @@ class LanguageServerHandler: if num_bytes is None: continue while line and line.strip(): - line = await self.process.stdout.readline() + line = self.process.stdout.readline() if not line: continue - body = await self.process.stdout.readexactly(num_bytes) + body = self._read_bytes_from_process(self.process, self.process.stdout, num_bytes) - # Use lock to prevent race conditions on tasks and task_counter - with self._tasks_lock: - self.tasks[self.task_counter] = asyncio.get_event_loop().create_task(self._handle_body(body)) - self.task_counter += 1 - except (BrokenPipeError, ConnectionResetError, StopLoopException): + self._handle_body(body) + except (BrokenPipeError, ConnectionResetError): pass return self._received_shutdown - - async def run_forever_stderr(self) -> None: + def run_forever_stderr(self) -> None: """ Continuously read from the language server process stderr and log the messages """ try: - while self.process and self.process.stderr and not self.process.stderr.at_eof(): - line = await self.process.stderr.readline() + while self.process and self.process.stderr and self.process.stderr.readable(): + line = self.process.stderr.readline() if not line: continue self._log("LSP stderr: " + line.decode(ENCODING, errors='replace')) - except (BrokenPipeError, ConnectionResetError, StopLoopException): + except (BrokenPipeError, ConnectionResetError): pass - async def _handle_body(self, body: bytes) -> None: + def _handle_body(self, body: bytes) -> None: """ Parse the body text received from the language server process and invoke the appropriate handler """ try: - await self._receive_payload(json.loads(body)) + self._receive_payload(json.loads(body)) except IOError as ex: self._log(f"malformed {ENCODING}: {ex}") except UnicodeDecodeError as ex: @@ -334,7 +349,7 @@ class LanguageServerHandler: except json.JSONDecodeError as ex: self._log(f"malformed JSON: {ex}") - async def _receive_payload(self, payload: StringDict) -> None: + def _receive_payload(self, payload: StringDict) -> None: """ Determine if the payload received from server is for a request, response, or notification and invoke the appropriate handler """ @@ -343,11 +358,11 @@ class LanguageServerHandler: try: if "method" in payload: if "id" in payload: - await self._request_handler(payload) + self._request_handler(payload) else: - await self._notification_handler(payload) + self._notification_handler(payload) elif "id" in payload: - await self._response_handler(payload) + self._response_handler(payload) else: self._log(f"Unknown payload type: {payload}") except Exception as err: @@ -357,33 +372,22 @@ class LanguageServerHandler: """ Send notification pertaining to the given method to the server with the given parameters """ - self._send_payload_sync(make_notification(method, params)) + self._send_payload(make_notification(method, params)) def send_response(self, request_id: Any, params: PayloadLike) -> None: """ Send response to the given request id to the server with the given parameters """ - # Use lock to prevent race conditions on tasks and task_counter - with self._tasks_lock: - self.tasks[self.task_counter] = asyncio.get_event_loop().create_task( - self._send_payload(make_response(request_id, params)) - ) - self.task_counter += 1 - + self._send_payload(make_response(request_id, params)) def send_error_response(self, request_id: Any, err: Error) -> None: """ Send error response to the given request id to the server with the given error """ # Use lock to prevent race conditions on tasks and task_counter - with self._tasks_lock: - self.tasks[self.task_counter] = asyncio.get_event_loop().create_task( - self._send_payload(make_error_response(request_id, err)) - ) - self.task_counter += 1 + self._send_payload(make_error_response(request_id, err)) - - async def send_request(self, method: str, params: Optional[dict] = None) -> PayloadLike: + def send_request(self, method: str, params: Optional[dict] = None) -> PayloadLike: """ Send request to the server, register the request id, and wait for the response """ @@ -397,39 +401,19 @@ class LanguageServerHandler: with self._response_handlers_lock: self._response_handlers[request_id] = request - async with request.cv: - await self._send_payload(make_request(method, request_id, params)) - self._log(f"Waiting for asyncio condition for request {method} with params:\n{params}") - await request.cv.wait() - self._log(f"Finished waiting, processing result") - if isinstance(request.error, Error): - raise MultilspyException(f"Could not process request {method} with params:\n{params}.\n Language server error: {request.error}") from request.error - self._log(f"Returning non-error result, which is:\n{request.result}") - return request.result + self._send_payload(make_request(method, request_id, params)) + self._log(f"Waiting for response to request {method} with params:\n{params}") + result = request.get_result() - def _send_payload_sync(self, payload: StringDict) -> None: - """ - Send the payload to the server by writing to its stdin synchronously - """ - if not self.process or not self.process.stdin: - return - msg = create_message(payload) - if self.logger: - self.logger("client", "server", payload) + self._log(f"Processing result") + if result.is_error(): + raise MultilspyException(f"Could not process request {method} with params:\n{params}.\n Language server error: {result.error}") from result.error + + self._log(f"Returning non-error result, which is:\n{result.payload}") + return result.payload - # Use lock to prevent concurrent writes to stdin that cause buffer corruption - with self._stdin_lock: - try: - self.process.stdin.writelines(msg) - except (BrokenPipeError, ConnectionResetError, OSError) as e: - # Log the error but don't raise to prevent cascading failures - if self.logger: - self.logger("client", "logger", f"Failed to write to stdin: {e}") - return - - - async def _send_payload(self, payload: StringDict) -> None: + def _send_payload(self, payload: StringDict) -> None: """ Send the payload to the server by writing to its stdin asynchronously. """ @@ -442,14 +426,13 @@ class LanguageServerHandler: with self._stdin_lock: try: self.process.stdin.writelines(msg) - await self.process.stdin.drain() + self.process.stdin.flush() except (BrokenPipeError, ConnectionResetError, OSError) as e: # Log the error but don't raise to prevent cascading failures if self.logger: self.logger("client", "logger", f"Failed to write to stdin: {e}") return - def on_request(self, method: str, cb) -> None: """ Register the callback function to handle requests from the server to the client for the given method @@ -462,7 +445,7 @@ class LanguageServerHandler: """ self.on_notification_handlers[method] = cb - async def _response_handler(self, response: StringDict) -> None: + def _response_handler(self, response: StringDict) -> None: """ Handle the response received from the server for a request, using the id to determine the request """ @@ -470,14 +453,13 @@ class LanguageServerHandler: request = self._response_handlers.pop(response["id"]) if "result" in response and "error" not in response: - await request.on_result(response["result"]) + request.on_result(response["result"]) elif "result" not in response and "error" in response: - await request.on_error(Error.from_lsp(response["error"])) + request.on_error(Error.from_lsp(response["error"])) else: - await request.on_error(Error(ErrorCodes.InvalidRequest, "")) + request.on_error(Error(ErrorCodes.InvalidRequest, "")) - - async def _request_handler(self, response: StringDict) -> None: + def _request_handler(self, response: StringDict) -> None: """ Handle the request received from the server: call the appropriate callback function and return the result """ @@ -495,13 +477,13 @@ class LanguageServerHandler: ) return try: - self.send_response(request_id, await handler(params)) + self.send_response(request_id, handler(params)) except Error as ex: self.send_error_response(request_id, ex) except Exception as ex: self.send_error_response(request_id, Error(ErrorCodes.InternalError, str(ex))) - async def _notification_handler(self, response: StringDict) -> None: + def _notification_handler(self, response: StringDict) -> None: """ Handle the notification received from the server: call the appropriate callback function """ @@ -512,7 +494,7 @@ class LanguageServerHandler: self._log(f"unhandled {method}") return try: - await handler(params) + handler(params) except asyncio.CancelledError: return except Exception as ex: diff --git a/src/solidlsp/lsp_request.py b/src/solidlsp/lsp_request.py index b46bf346..984407b5 100644 --- a/src/solidlsp/lsp_request.py +++ b/src/solidlsp/lsp_request.py @@ -2,56 +2,56 @@ from typing import List, Union from multilspy.lsp_protocol_handler import lsp_types -class LspRequest: +class SolidLspRequest: def __init__(self, send_request): self.send_request = send_request - async def implementation( + def implementation( self, params: lsp_types.ImplementationParams ) -> Union["lsp_types.Definition", List["lsp_types.LocationLink"], None]: """A request to resolve the implementation locations of a symbol at a given text document position. The request's parameter is of type [TextDocumentPositionParams] (#TextDocumentPositionParams) the response is of type {@link Definition} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/implementation", params) + return self.send_request("textDocument/implementation", params) - async def type_definition( + def type_definition( self, params: lsp_types.TypeDefinitionParams ) -> Union["lsp_types.Definition", List["lsp_types.LocationLink"], None]: """A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type [TextDocumentPositionParams] (#TextDocumentPositionParams) the response is of type {@link Definition} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/typeDefinition", params) + return self.send_request("textDocument/typeDefinition", params) - async def document_color( + def document_color( self, params: lsp_types.DocumentColorParams ) -> List["lsp_types.ColorInformation"]: """A request to list all color symbols found in a given text document. The request's parameter is of type {@link DocumentColorParams} the response is of type {@link ColorInformation ColorInformation[]} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/documentColor", params) + return self.send_request("textDocument/documentColor", params) - async def color_presentation( + def color_presentation( self, params: lsp_types.ColorPresentationParams ) -> List["lsp_types.ColorPresentation"]: """A request to list all presentation for a color. The request's parameter is of type {@link ColorPresentationParams} the response is of type {@link ColorInformation ColorInformation[]} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/colorPresentation", params) + return self.send_request("textDocument/colorPresentation", params) - async def folding_range( + def folding_range( self, params: lsp_types.FoldingRangeParams ) -> Union[List["lsp_types.FoldingRange"], None]: """A request to provide folding ranges in a document. The request's parameter is of type {@link FoldingRangeParams}, the response is of type {@link FoldingRangeList} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/foldingRange", params) + return self.send_request("textDocument/foldingRange", params) - async def declaration( + def declaration( self, params: lsp_types.DeclarationParams ) -> Union["lsp_types.Declaration", List["lsp_types.LocationLink"], None]: """A request to resolve the type definition locations of a symbol at a given text @@ -59,129 +59,129 @@ class LspRequest: (#TextDocumentPositionParams) the response is of type {@link Declaration} or a typed array of {@link DeclarationLink} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/declaration", params) + return self.send_request("textDocument/declaration", params) - async def selection_range( + def selection_range( self, params: lsp_types.SelectionRangeParams ) -> Union[List["lsp_types.SelectionRange"], None]: """A request to provide selection ranges in a document. The request's parameter is of type {@link SelectionRangeParams}, the response is of type {@link SelectionRange SelectionRange[]} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/selectionRange", params) + return self.send_request("textDocument/selectionRange", params) - async def prepare_call_hierarchy( + def prepare_call_hierarchy( self, params: lsp_types.CallHierarchyPrepareParams ) -> Union[List["lsp_types.CallHierarchyItem"], None]: """A request to result a `CallHierarchyItem` in a document at a given position. Can be used as an input to an incoming or outgoing call hierarchy. @since 3.16.0""" - return await self.send_request("textDocument/prepareCallHierarchy", params) + return self.send_request("textDocument/prepareCallHierarchy", params) - async def incoming_calls( + def incoming_calls( self, params: lsp_types.CallHierarchyIncomingCallsParams ) -> Union[List["lsp_types.CallHierarchyIncomingCall"], None]: """A request to resolve the incoming calls for a given `CallHierarchyItem`. @since 3.16.0""" - return await self.send_request("callHierarchy/incomingCalls", params) + return self.send_request("callHierarchy/incomingCalls", params) - async def outgoing_calls( + def outgoing_calls( self, params: lsp_types.CallHierarchyOutgoingCallsParams ) -> Union[List["lsp_types.CallHierarchyOutgoingCall"], None]: """A request to resolve the outgoing calls for a given `CallHierarchyItem`. @since 3.16.0""" - return await self.send_request("callHierarchy/outgoingCalls", params) + return self.send_request("callHierarchy/outgoingCalls", params) - async def semantic_tokens_full( + def semantic_tokens_full( self, params: lsp_types.SemanticTokensParams ) -> Union["lsp_types.SemanticTokens", None]: """@since 3.16.0""" - return await self.send_request("textDocument/semanticTokens/full", params) + return self.send_request("textDocument/semanticTokens/full", params) - async def semantic_tokens_delta( + def semantic_tokens_delta( self, params: lsp_types.SemanticTokensDeltaParams ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]: """@since 3.16.0""" - return await self.send_request("textDocument/semanticTokens/full/delta", params) + return self.send_request("textDocument/semanticTokens/full/delta", params) - async def semantic_tokens_range( + def semantic_tokens_range( self, params: lsp_types.SemanticTokensRangeParams ) -> Union["lsp_types.SemanticTokens", None]: """@since 3.16.0""" - return await self.send_request("textDocument/semanticTokens/range", params) + return self.send_request("textDocument/semanticTokens/range", params) - async def linked_editing_range( + def linked_editing_range( self, params: lsp_types.LinkedEditingRangeParams ) -> Union["lsp_types.LinkedEditingRanges", None]: """A request to provide ranges that can be edited together. @since 3.16.0""" - return await self.send_request("textDocument/linkedEditingRange", params) + return self.send_request("textDocument/linkedEditingRange", params) - async def will_create_files( + def will_create_files( self, params: lsp_types.CreateFilesParams ) -> Union["lsp_types.WorkspaceEdit", None]: """The will create files request is sent from the client to the server before files are actually created as long as the creation is triggered from within the client. @since 3.16.0""" - return await self.send_request("workspace/willCreateFiles", params) + return self.send_request("workspace/willCreateFiles", params) - async def will_rename_files( + def will_rename_files( self, params: lsp_types.RenameFilesParams ) -> Union["lsp_types.WorkspaceEdit", None]: """The will rename files request is sent from the client to the server before files are actually renamed as long as the rename is triggered from within the client. @since 3.16.0""" - return await self.send_request("workspace/willRenameFiles", params) + return self.send_request("workspace/willRenameFiles", params) - async def will_delete_files( + def will_delete_files( self, params: lsp_types.DeleteFilesParams ) -> Union["lsp_types.WorkspaceEdit", None]: """The did delete files notification is sent from the client to the server when files were deleted from within the client. @since 3.16.0""" - return await self.send_request("workspace/willDeleteFiles", params) + return self.send_request("workspace/willDeleteFiles", params) - async def moniker( + def moniker( self, params: lsp_types.MonikerParams ) -> Union[List["lsp_types.Moniker"], None]: """A request to get the moniker of a symbol at a given text document position. The request parameter is of type {@link TextDocumentPositionParams}. The response is of type {@link Moniker Moniker[]} or `null`.""" - return await self.send_request("textDocument/moniker", params) + return self.send_request("textDocument/moniker", params) - async def prepare_type_hierarchy( + def prepare_type_hierarchy( self, params: lsp_types.TypeHierarchyPrepareParams ) -> Union[List["lsp_types.TypeHierarchyItem"], None]: """A request to result a `TypeHierarchyItem` in a document at a given position. Can be used as an input to a subtypes or supertypes type hierarchy. @since 3.17.0""" - return await self.send_request("textDocument/prepareTypeHierarchy", params) + return self.send_request("textDocument/prepareTypeHierarchy", params) - async def type_hierarchy_supertypes( + def type_hierarchy_supertypes( self, params: lsp_types.TypeHierarchySupertypesParams ) -> Union[List["lsp_types.TypeHierarchyItem"], None]: """A request to resolve the supertypes for a given `TypeHierarchyItem`. @since 3.17.0""" - return await self.send_request("typeHierarchy/supertypes", params) + return self.send_request("typeHierarchy/supertypes", params) - async def type_hierarchy_subtypes( + def type_hierarchy_subtypes( self, params: lsp_types.TypeHierarchySubtypesParams ) -> Union[List["lsp_types.TypeHierarchyItem"], None]: """A request to resolve the subtypes for a given `TypeHierarchyItem`. @since 3.17.0""" - return await self.send_request("typeHierarchy/subtypes", params) + return self.send_request("typeHierarchy/subtypes", params) - async def inline_value( + def inline_value( self, params: lsp_types.InlineValueParams ) -> Union[List["lsp_types.InlineValue"], None]: """A request to provide inline values in a document. The request's parameter is of @@ -189,9 +189,9 @@ class LspRequest: {@link InlineValue InlineValue[]} or a Thenable that resolves to such. @since 3.17.0""" - return await self.send_request("textDocument/inlineValue", params) + return self.send_request("textDocument/inlineValue", params) - async def inlay_hint( + def inlay_hint( self, params: lsp_types.InlayHintParams ) -> Union[List["lsp_types.InlayHint"], None]: """A request to provide inlay hints in a document. The request's parameter is of @@ -199,9 +199,9 @@ class LspRequest: {@link InlayHint InlayHint[]} or a Thenable that resolves to such. @since 3.17.0""" - return await self.send_request("textDocument/inlayHint", params) + return self.send_request("textDocument/inlayHint", params) - async def resolve_inlay_hint( + def resolve_inlay_hint( self, params: lsp_types.InlayHint ) -> "lsp_types.InlayHint": """A request to resolve additional properties for an inlay hint. @@ -209,25 +209,25 @@ class LspRequest: of type {@link InlayHint} or a Thenable that resolves to such. @since 3.17.0""" - return await self.send_request("inlayHint/resolve", params) + return self.send_request("inlayHint/resolve", params) - async def text_document_diagnostic( + def text_document_diagnostic( self, params: lsp_types.DocumentDiagnosticParams ) -> "lsp_types.DocumentDiagnosticReport": """The document diagnostic request definition. @since 3.17.0""" - return await self.send_request("textDocument/diagnostic", params) + return self.send_request("textDocument/diagnostic", params) - async def workspace_diagnostic( + def workspace_diagnostic( self, params: lsp_types.WorkspaceDiagnosticParams ) -> "lsp_types.WorkspaceDiagnosticReport": """The workspace diagnostic request definition. @since 3.17.0""" - return await self.send_request("workspace/diagnostic", params) + return self.send_request("workspace/diagnostic", params) - async def initialize( + def initialize( self, params: lsp_types.InitializeParams ) -> "lsp_types.InitializeResult": """The initialize request is sent from the client to the server. @@ -235,16 +235,16 @@ class LspRequest: The requests parameter is of type {@link InitializeParams} the response if of type {@link InitializeResult} of a Thenable that resolves to such.""" - return await self.send_request("initialize", params) + return self.send_request("initialize", params) - async def shutdown(self) -> None: + def shutdown(self) -> None: """A shutdown request is sent from the client to the server. It is sent once when the client decides to shutdown the server. The only notification that is sent after a shutdown request is the exit event.""" - return await self.send_request("shutdown") + return self.send_request("shutdown") - async def will_save_wait_until( + def will_save_wait_until( self, params: lsp_types.WillSaveTextDocumentParams ) -> Union[List["lsp_types.TextEdit"], None]: """A document will save request is sent from the client to the server before @@ -253,9 +253,9 @@ class LspRequest: clients might drop results if computing the text edits took too long or if a server constantly fails on this request. This is done to keep the save fast and reliable.""" - return await self.send_request("textDocument/willSaveWaitUntil", params) + return self.send_request("textDocument/willSaveWaitUntil", params) - async def completion( + def completion( self, params: lsp_types.CompletionParams ) -> Union[List["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]: """Request to request completion at a given text document position. The request's @@ -268,30 +268,30 @@ class LspRequest: request. However, properties that are needed for the initial sorting and filtering, like `sortText`, `filterText`, `insertText`, and `textEdit`, must not be changed during resolve. """ - return await self.send_request("textDocument/completion", params) + return self.send_request("textDocument/completion", params) - async def resolve_completion_item( + def resolve_completion_item( self, params: lsp_types.CompletionItem ) -> "lsp_types.CompletionItem": """Request to resolve additional information for a given completion item.The request's parameter is of type {@link CompletionItem} the response is of type {@link CompletionItem} or a Thenable that resolves to such.""" - return await self.send_request("completionItem/resolve", params) + return self.send_request("completionItem/resolve", params) - async def hover( + def hover( self, params: lsp_types.HoverParams ) -> Union["lsp_types.Hover", None]: """Request to request hover information at a given text document position. The request's parameter is of type {@link TextDocumentPosition} the response is of type {@link Hover} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/hover", params) + return self.send_request("textDocument/hover", params) - async def signature_help( + def signature_help( self, params: lsp_types.SignatureHelpParams ) -> Union["lsp_types.SignatureHelp", None]: - return await self.send_request("textDocument/signatureHelp", params) + return self.send_request("textDocument/signatureHelp", params) - async def definition( + def definition( self, params: lsp_types.DefinitionParams ) -> Union["lsp_types.Definition", List["lsp_types.LocationLink"], None]: """A request to resolve the definition location of a symbol at a given text @@ -299,27 +299,27 @@ class LspRequest: (#TextDocumentPosition) the response is of either type {@link Definition} or a typed array of {@link DefinitionLink} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/definition", params) + return self.send_request("textDocument/definition", params) - async def references( + def references( self, params: lsp_types.ReferenceParams ) -> Union[List["lsp_types.Location"], None]: """A request to resolve project-wide references for the symbol denoted by the given text document position. The request's parameter is of type {@link ReferenceParams} the response is of type {@link Location Location[]} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/references", params) + return self.send_request("textDocument/references", params) - async def document_highlight( + def document_highlight( self, params: lsp_types.DocumentHighlightParams ) -> Union[List["lsp_types.DocumentHighlight"], None]: """Request to resolve a {@link DocumentHighlight} for a given text document position. The request's parameter is of type [TextDocumentPosition] (#TextDocumentPosition) the request response is of type [DocumentHighlight[]] (#DocumentHighlight) or a Thenable that resolves to such.""" - return await self.send_request("textDocument/documentHighlight", params) + return self.send_request("textDocument/documentHighlight", params) - async def document_symbol( + def document_symbol( self, params: lsp_types.DocumentSymbolParams ) -> Union[ List["lsp_types.SymbolInformation"], List["lsp_types.DocumentSymbol"], None @@ -328,23 +328,23 @@ class LspRequest: parameter is of type {@link TextDocumentIdentifier} the response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable that resolves to such.""" - return await self.send_request("textDocument/documentSymbol", params) + return self.send_request("textDocument/documentSymbol", params) - async def code_action( + def code_action( self, params: lsp_types.CodeActionParams ) -> Union[List[Union["lsp_types.Command", "lsp_types.CodeAction"]], None]: """A request to provide commands for the given text document and range.""" - return await self.send_request("textDocument/codeAction", params) + return self.send_request("textDocument/codeAction", params) - async def resolve_code_action( + def resolve_code_action( self, params: lsp_types.CodeAction ) -> "lsp_types.CodeAction": """Request to resolve additional information for a given code action.The request's parameter is of type {@link CodeAction} the response is of type {@link CodeAction} or a Thenable that resolves to such.""" - return await self.send_request("codeAction/resolve", params) + return self.send_request("codeAction/resolve", params) - async def workspace_symbol( + def workspace_symbol( self, params: lsp_types.WorkspaceSymbolParams ) -> Union[ List["lsp_types.SymbolInformation"], List["lsp_types.WorkspaceSymbol"], None @@ -358,78 +358,78 @@ class LspRequest: need to advertise support for WorkspaceSymbols via the client capability `workspace.symbol.resolveSupport`. """ - return await self.send_request("workspace/symbol", params) + return self.send_request("workspace/symbol", params) - async def resolve_workspace_symbol( + def resolve_workspace_symbol( self, params: lsp_types.WorkspaceSymbol ) -> "lsp_types.WorkspaceSymbol": """A request to resolve the range inside the workspace symbol's location. @since 3.17.0""" - return await self.send_request("workspaceSymbol/resolve", params) + return self.send_request("workspaceSymbol/resolve", params) - async def code_lens( + def code_lens( self, params: lsp_types.CodeLensParams ) -> Union[List["lsp_types.CodeLens"], None]: """A request to provide code lens for the given text document.""" - return await self.send_request("textDocument/codeLens", params) + return self.send_request("textDocument/codeLens", params) - async def resolve_code_lens( + def resolve_code_lens( self, params: lsp_types.CodeLens ) -> "lsp_types.CodeLens": """A request to resolve a command for a given code lens.""" - return await self.send_request("codeLens/resolve", params) + return self.send_request("codeLens/resolve", params) - async def document_link( + def document_link( self, params: lsp_types.DocumentLinkParams ) -> Union[List["lsp_types.DocumentLink"], None]: """A request to provide document links""" - return await self.send_request("textDocument/documentLink", params) + return self.send_request("textDocument/documentLink", params) - async def resolve_document_link( + def resolve_document_link( self, params: lsp_types.DocumentLink ) -> "lsp_types.DocumentLink": """Request to resolve additional information for a given document link. The request's parameter is of type {@link DocumentLink} the response is of type {@link DocumentLink} or a Thenable that resolves to such.""" - return await self.send_request("documentLink/resolve", params) + return self.send_request("documentLink/resolve", params) - async def formatting( + def formatting( self, params: lsp_types.DocumentFormattingParams ) -> Union[List["lsp_types.TextEdit"], None]: """A request to to format a whole document.""" - return await self.send_request("textDocument/formatting", params) + return self.send_request("textDocument/formatting", params) - async def range_formatting( + def range_formatting( self, params: lsp_types.DocumentRangeFormattingParams ) -> Union[List["lsp_types.TextEdit"], None]: """A request to to format a range in a document.""" - return await self.send_request("textDocument/rangeFormatting", params) + return self.send_request("textDocument/rangeFormatting", params) - async def on_type_formatting( + def on_type_formatting( self, params: lsp_types.DocumentOnTypeFormattingParams ) -> Union[List["lsp_types.TextEdit"], None]: """A request to format a document on type.""" - return await self.send_request("textDocument/onTypeFormatting", params) + return self.send_request("textDocument/onTypeFormatting", params) - async def rename( + def rename( self, params: lsp_types.RenameParams ) -> Union["lsp_types.WorkspaceEdit", None]: """A request to rename a symbol.""" - return await self.send_request("textDocument/rename", params) + return self.send_request("textDocument/rename", params) - async def prepare_rename( + def prepare_rename( self, params: lsp_types.PrepareRenameParams ) -> Union["lsp_types.PrepareRenameResult", None]: """A request to test and perform the setup necessary for a rename. @since 3.16 - support for default behavior""" - return await self.send_request("textDocument/prepareRename", params) + return self.send_request("textDocument/prepareRename", params) - async def execute_command( + def execute_command( self, params: lsp_types.ExecuteCommandParams ) -> Union["lsp_types.LSPAny", None]: """A request send from the client to the server to execute a command. The request might return a workspace edit which the client will apply to the workspace.""" - return await self.send_request("workspace/executeCommand", params) + return self.send_request("workspace/executeCommand", params) From a43d0aa02146fa9e08c5cbfecc98f8d1efdb586d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 13:20:06 +0200 Subject: [PATCH 03/30] Add language server implementations from multilspy (unmodified) --- .gitignore | 1 + .../clangd_language_server.py | 190 ++++ .../initialize_params.json | 36 + .../runtime_dependencies.json | 29 + .../dart_language_server.py | 143 +++ .../initialize_params.json | 23 + .../runtime_dependencies.json | 45 + .../eclipse_jdtls/eclipse_jdtls.py | 422 ++++++++ .../eclipse_jdtls/initialize_params.json | 849 ++++++++++++++++ .../eclipse_jdtls/runtime_dependencies.json | 72 ++ src/solidlsp/language_servers/gopls/gopls.py | 154 +++ .../gopls/initialize_params.json | 46 + .../intelephense/initialize_params.json | 36 + .../intelephense/intelephense.py | 200 ++++ .../intelephense/runtime_dependencies.json | 10 + .../initialize_params.json | 905 +++++++++++++++++ .../jedi_language_server/jedi_server.py | 123 +++ .../initialize_params.json | 521 ++++++++++ .../kotlin_language_server.py | 227 +++++ .../runtime_dependencies.json | 41 + .../omnisharp/initialize_params.json | 631 ++++++++++++ .../language_servers/omnisharp/omnisharp.py | 406 ++++++++ .../omnisharp/runtime_dependencies.json | 441 +++++++++ .../workspace_did_change_configuration.json | 111 +++ .../pyright_language_server/pyright_server.py | 246 +++++ .../rust_analyzer/initialize_params.json | 917 ++++++++++++++++++ .../rust_analyzer/runtime_dependencies.json | 29 + .../rust_analyzer/rust_analyzer.py | 185 ++++ .../solargraph/initialize_params.json | 15 + .../solargraph/runtime_dependencies.json | 11 + .../language_servers/solargraph/solargraph.py | 189 ++++ .../initialize_params.json | 906 +++++++++++++++++ .../runtime_dependencies.json | 16 + .../typescript_language_server.py | 245 +++++ 34 files changed, 8421 insertions(+) create mode 100644 src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py create mode 100644 src/solidlsp/language_servers/clangd_language_server/initialize_params.json create mode 100644 src/solidlsp/language_servers/clangd_language_server/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/dart_language_server/dart_language_server.py create mode 100644 src/solidlsp/language_servers/dart_language_server/initialize_params.json create mode 100644 src/solidlsp/language_servers/dart_language_server/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py create mode 100644 src/solidlsp/language_servers/eclipse_jdtls/initialize_params.json create mode 100644 src/solidlsp/language_servers/eclipse_jdtls/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/gopls/gopls.py create mode 100644 src/solidlsp/language_servers/gopls/initialize_params.json create mode 100644 src/solidlsp/language_servers/intelephense/initialize_params.json create mode 100644 src/solidlsp/language_servers/intelephense/intelephense.py create mode 100644 src/solidlsp/language_servers/intelephense/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/jedi_language_server/initialize_params.json create mode 100644 src/solidlsp/language_servers/jedi_language_server/jedi_server.py create mode 100644 src/solidlsp/language_servers/kotlin_language_server/initialize_params.json create mode 100644 src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py create mode 100644 src/solidlsp/language_servers/kotlin_language_server/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/omnisharp/initialize_params.json create mode 100644 src/solidlsp/language_servers/omnisharp/omnisharp.py create mode 100644 src/solidlsp/language_servers/omnisharp/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json create mode 100644 src/solidlsp/language_servers/pyright_language_server/pyright_server.py create mode 100644 src/solidlsp/language_servers/rust_analyzer/initialize_params.json create mode 100644 src/solidlsp/language_servers/rust_analyzer/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py create mode 100644 src/solidlsp/language_servers/solargraph/initialize_params.json create mode 100644 src/solidlsp/language_servers/solargraph/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/solargraph/solargraph.py create mode 100644 src/solidlsp/language_servers/typescript_language_server/initialize_params.json create mode 100644 src/solidlsp/language_servers/typescript_language_server/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py diff --git a/.gitignore b/.gitignore index ce9c54f5..bee98b92 100644 --- a/.gitignore +++ b/.gitignore @@ -202,6 +202,7 @@ pylint.html /agent-ui # dynamic LS installations /src/multilspy/language_servers/*/static +/src/solidlsp/language_servers/*/static # Cache is in .multilspy **/.multilspy/** diff --git a/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py new file mode 100644 index 00000000..e28aa2c0 --- /dev/null +++ b/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py @@ -0,0 +1,190 @@ +""" +Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. +""" + +import asyncio +import json +import logging +import os +import stat +import pathlib +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils + + +class ClangdLanguageServer(LanguageServer): + """ + Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. + As the project gets bigger in size, building index will take time. Try running clangd multiple times to ensure index is built properly. + Also make sure compile_commands.json is created at root of the source directory. Check clangd test case for example. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + clangd_executable_path = self.setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=clangd_executable_path, cwd=repository_root_path), + "cpp", + ) + self.server_ready = asyncio.Event() + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for ClangdLanguageServer. + """ + platform_id = PlatformUtils.get_platform_id() + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f: + d = json.load(f) + del d["_description"] + + assert platform_id.value in [ + "linux-x64", + "win-x64", + "osx-arm64", + ], "Unsupported platform: " + platform_id.value + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [ + dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value + ] + assert len(runtime_dependencies) == 1 + # Select dependency matching the current platform + dependency = next((dep for dep in runtime_dependencies if dep["platformId"] == platform_id.value), None) + if dependency is None: + raise RuntimeError(f"No runtime dependency found for platform {platform_id.value}") + + clangd_ls_dir = os.path.join(os.path.dirname(__file__), "static", "clangd") + clangd_executable_path = os.path.join(clangd_ls_dir, "clangd_19.1.2", "bin", dependency["binaryName"]) + if not os.path.exists(clangd_executable_path): + clangd_url = dependency["url"] + logger.log(f"Clangd executable not found at {clangd_executable_path}. Downloading from {clangd_url}", logging.INFO) + os.makedirs(clangd_ls_dir, exist_ok=True) + if dependency["archiveType"] == "zip": + FileUtils.download_and_extract_archive( + logger, clangd_url, clangd_ls_dir, dependency["archiveType"] + ) + else: + raise RuntimeError(f"Unsupported archive type: {dependency['archiveType']}") + if not os.path.exists(clangd_executable_path): + raise FileNotFoundError( + f"Clangd executable not found at {clangd_executable_path}.\n" + "Make sure you have installed clangd. See https://clangd.llvm.org/installation" + ) + os.chmod(clangd_executable_path, stat.S_IEXEC) + + return clangd_executable_path + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the clangd Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["ClangdLanguageServer"]: + """ + Starts the Clangd Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + """ + async def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + async def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def check_experimental_status(params): + if params["quiescent"] == True: + self.server_ready.set() + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + async with super().start_server(): + self.logger.log("Starting Clangd server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": ['.', '<', '>', ':', '"', '/', '*'], + "resolveProvider": False, + } + + self.server.notify.initialized({}) + + self.completions_available.set() + # set ready flag + self.server_ready.set() + await self.server_ready.wait() + + yield self diff --git a/src/solidlsp/language_servers/clangd_language_server/initialize_params.json b/src/solidlsp/language_servers/clangd_language_server/initialize_params.json new file mode 100644 index 00000000..4330560e --- /dev/null +++ b/src/solidlsp/language_servers/clangd_language_server/initialize_params.json @@ -0,0 +1,36 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": true, + "dynamicRegistration": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true + } + }, + "definition": { + "dynamicRegistration": true + } + }, + "workspace": { + "workspaceFolders": true, + "didChangeConfiguration": { + "dynamicRegistration": true + } + } + }, + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/clangd_language_server/runtime_dependencies.json b/src/solidlsp/language_servers/clangd_language_server/runtime_dependencies.json new file mode 100644 index 00000000..53f5b7f3 --- /dev/null +++ b/src/solidlsp/language_servers/clangd_language_server/runtime_dependencies.json @@ -0,0 +1,29 @@ +{ + "_description": "Used to download the runtime dependencies for running Clangd.", + "runtimeDependencies": [ + { + "id": "Clangd", + "description": "Clangd for Linux (x64)", + "url": "https://github.com/clangd/clangd/releases/download/19.1.2/clangd-linux-19.1.2.zip", + "platformId": "linux-x64", + "archiveType": "zip", + "binaryName": "clangd" + }, + { + "id": "Clangd", + "description": "Clangd for Windows (x64)", + "url": "https://github.com/clangd/clangd/releases/download/19.1.2/clangd-windows-19.1.2.zip", + "platformId": "windows-x64", + "archiveType": "zip", + "binaryName": "clangd.exe" + }, + { + "id": "Clangd", + "description": "Clangd for macOS (Arm64)", + "url": "https://github.com/clangd/clangd/releases/download/19.1.2/clangd-mac-19.1.2.zip", + "platformId": "osx-arm64", + "archiveType": "zip", + "binaryName": "clangd" + } + ] +} diff --git a/src/solidlsp/language_servers/dart_language_server/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server/dart_language_server.py new file mode 100644 index 00000000..cd8ea518 --- /dev/null +++ b/src/solidlsp/language_servers/dart_language_server/dart_language_server.py @@ -0,0 +1,143 @@ +from contextlib import asynccontextmanager +import logging +import os +import pathlib +import shutil +import stat +from typing import AsyncIterator +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +import json +from multilspy.multilspy_utils import FileUtils, PlatformUtils + + +class DartLanguageServer(LanguageServer): + """ + Provides Dart specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Dart. + """ + + def __init__(self, config, logger, repository_root_path): + """ + Creates a DartServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + + executable_path = self.setup_runtime_dependencies(logger) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), + "dart", + ) + + def setup_runtime_dependencies(self, logger: "MultilspyLogger") -> str: + platform_id = PlatformUtils.get_platform_id() + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [ + dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value + ] + + assert len(runtime_dependencies) == 1 + dependency = runtime_dependencies[0] + + dart_ls_dir = os.path.join(os.path.dirname(__file__), "static", "dart-language-server") + dart_executable_path = os.path.join(dart_ls_dir, dependency["binaryName"]) + + if not os.path.exists(dart_ls_dir): + os.makedirs(dart_ls_dir) + FileUtils.download_and_extract_archive( + logger, dependency["url"], dart_ls_dir, dependency["archiveType"] + ) + + + assert os.path.exists(dart_executable_path) + os.chmod(dart_executable_path, stat.S_IEXEC) + + return f"{dart_executable_path} language-server --client-id multilspy.dart --client-version 1.2" + + + def _get_initialize_params(self, repository_absolute_path: str): + """ + Returns the initialize params for the Dart Language Server. + """ + with open( + os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r" + ) as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path( + repository_absolute_path + ).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["DartLanguageServer"]: + """ + Start the language server and yield when the server is ready. + """ + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def check_experimental_status(params): + pass + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request( + "workspace/executeClientCommand", execute_client_command_handler + ) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification( + "experimental/serverStatus", check_experimental_status + ) + + async with super().start_server(): + self.logger.log( + "Starting dart-language-server server process", logging.INFO + ) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log( + "Sending initialize request to dart-language-server", + logging.DEBUG, + ) + init_response = await self.server.send_request( + "initialize", initialize_params + ) + self.logger.log( + f"Received initialize response from dart-language-server: {init_response}", + logging.INFO, + ) + + self.server.notify.initialized({}) + + yield self diff --git a/src/solidlsp/language_servers/dart_language_server/initialize_params.json b/src/solidlsp/language_servers/dart_language_server/initialize_params.json new file mode 100644 index 00000000..e90e408a --- /dev/null +++ b/src/solidlsp/language_servers/dart_language_server/initialize_params.json @@ -0,0 +1,23 @@ +{ + "_description": "This file contains the initialization parameters for the Dart Language Server.", + "processId": "$processId", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": {}, + "initializationOptions": { + "onlyAnalyzeProjectsWithOpenFiles": false, + "suggestFromUnimportedLibraries": true, + "closingLabels": false, + "outline": false, + "flutterOutline": false, + "allowOpenUri": false + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] + +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/dart_language_server/runtime_dependencies.json b/src/solidlsp/language_servers/dart_language_server/runtime_dependencies.json new file mode 100644 index 00000000..32e49299 --- /dev/null +++ b/src/solidlsp/language_servers/dart_language_server/runtime_dependencies.json @@ -0,0 +1,45 @@ +{ + "_description": "Used to download the runtime dependencies for running Dart Language Server, downloaded from https://dart.dev/get-dart/archive", + "runtimeDependencies": [ + { + "id": "DartLanguageServer", + "description": "Dart Language Server for Linux (x64)", + "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-linux-x64-release.zip", + "platformId": "linux-x64", + "archiveType": "zip", + "binaryName": "dart-sdk/bin/dart" + }, + { + "id": "DartLanguageServer", + "description": "Dart Language Server for Windows (x64)", + "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-x64-release.zip", + "platformId": "win-x64", + "archiveType": "zip", + "binaryName": "dart-sdk/bin/dart.exe" + }, + { + "id": "DartLanguageServer", + "description": "Dart Language Server for Windows (arm64)", + "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-windows-arm64-release.zip", + "platformId": "win-arm64", + "archiveType": "zip", + "binaryName": "dart-sdk/bin/dart.exe" + }, + { + "id": "DartLanguageServer", + "description": "Dart Language Server for macOS (x64)", + "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-x64-release.zip", + "platformId": "osx-x64", + "archiveType": "zip", + "binaryName": "dart-sdk/bin/dart" + }, + { + "id": "DartLanguageServer", + "description": "Dart Language Server for macOS (arm64)", + "url": "https://storage.googleapis.com/dart-archive/channels/stable/release/3.7.1/sdk/dartsdk-macos-arm64-release.zip", + "platformId": "osx-arm64", + "archiveType": "zip", + "binaryName": "dart-sdk/bin/dart" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py new file mode 100644 index 00000000..a88dc1b6 --- /dev/null +++ b/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py @@ -0,0 +1,422 @@ +""" +Provides Java specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Java. +""" + +import asyncio +import dataclasses +import json +import logging +import os +import pathlib +import shutil +import stat +import uuid +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from overrides import override + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_settings import MultilspySettings +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils +from pathlib import PurePath + + +@dataclasses.dataclass +class RuntimeDependencyPaths: + """ + Stores the paths to the runtime dependencies of EclipseJDTLS + """ + + gradle_path: str + lombok_jar_path: str + jre_path: str + jre_home_path: str + jdtls_launcher_jar_path: str + jdtls_readonly_config_path: str + intellicode_jar_path: str + intellisense_members_path: str + + +class EclipseJDTLS(LanguageServer): + """ + The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a new EclipseJDTLS instance initializing the language server settings appropriately. + This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + + runtime_dependency_paths = self.setupRuntimeDependencies(logger, config) + self.runtime_dependency_paths = runtime_dependency_paths + + # ws_dir is the workspace directory for the EclipseJDTLS server + ws_dir = str( + PurePath( + MultilspySettings.get_language_server_directory(), + "EclipseJDTLS", + "workspaces", + uuid.uuid4().hex, + ) + ) + + # shared_cache_location is the global cache used by Eclipse JDTLS across all workspaces + shared_cache_location = str( + PurePath(MultilspySettings.get_global_cache_directory(), "lsp", "EclipseJDTLS", "sharedIndex") + ) + + jre_path = self.runtime_dependency_paths.jre_path + lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path + + jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path + + os.makedirs(ws_dir, exist_ok=True) + + data_dir = str(PurePath(ws_dir, "data_dir")) + jdtls_config_path = str(PurePath(ws_dir, "config_path")) + + jdtls_readonly_config_path = self.runtime_dependency_paths.jdtls_readonly_config_path + + if not os.path.exists(jdtls_config_path): + shutil.copytree(jdtls_readonly_config_path, jdtls_config_path) + + for static_path in [ + jre_path, + lombok_jar_path, + jdtls_launcher_jar, + jdtls_config_path, + jdtls_readonly_config_path, + ]: + assert os.path.exists(static_path), static_path + + # TODO: Add "self.runtime_dependency_paths.jre_home_path"/bin to $PATH as well + proc_env = {"syntaxserver": "false", "JAVA_HOME": self.runtime_dependency_paths.jre_home_path} + proc_cwd = repository_root_path + cmd = " ".join( + [ + jre_path, + "--add-modules=ALL-SYSTEM", + "--add-opens", + "java.base/java.util=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "--add-opens", + "java.base/sun.nio.fs=ALL-UNNAMED", + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Djava.import.generatesMetadataFilesAtProjectRoot=false", + "-Dfile.encoding=utf8", + "-noverify", + "-XX:+UseParallelGC", + "-XX:GCTimeRatio=4", + "-XX:AdaptiveSizePolicyWeight=90", + "-Dsun.zip.disableMemoryMapping=true", + "-Djava.lsp.joinOnCompletion=true", + "-Xmx3G", + "-Xms100m", + "-Xlog:disable", + "-Dlog.level=ALL", + f'"-javaagent:{lombok_jar_path}"', + f'"-Djdt.core.sharedIndexLocation={shared_cache_location}"', + "-jar", + f'"{jdtls_launcher_jar}"', + "-configuration", + f'"{jdtls_config_path}"', + "-data", + f'"{data_dir}"', + ] + ) + + self.service_ready_event = asyncio.Event() + self.intellicode_enable_command_available = asyncio.Event() + self.initialize_searcher_command_available = asyncio.Event() + + super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java") + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # Ignore common Java build directories from different build tools: + # - Maven: target + # - Gradle: build, .gradle + # - Eclipse: bin, .settings + # - IntelliJ IDEA: out, .idea + # - General: classes, dist, lib + return super().is_ignored_dirname(dirname) or dirname in [ + "target", # Maven + "build", # Gradle + "bin", # Eclipse + "out", # IntelliJ IDEA + "classes", # General + "dist", # General + "lib" # General + ] + + def setupRuntimeDependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> RuntimeDependencyPaths: + """ + Setup runtime dependencies for EclipseJDTLS. + """ + platformId = PlatformUtils.get_platform_id() + + with open(str(PurePath(os.path.dirname(__file__), "runtime_dependencies.json")), "r", encoding="utf-8") as f: + runtimeDependencies = json.load(f) + del runtimeDependencies["_description"] + + os.makedirs(str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static")), exist_ok=True) + + # assert platformId.value in [ + # "linux-x64", + # "win-x64", + # ], "Only linux-x64 platform is supported for in multilspy at the moment" + + gradle_path = str( + PurePath( + os.path.abspath(os.path.dirname(__file__)), + "static/gradle-7.3.3", + ) + ) + + if not os.path.exists(gradle_path): + FileUtils.download_and_extract_archive( + logger, + runtimeDependencies["gradle"]["platform-agnostic"]["url"], + str(PurePath(gradle_path).parent), + runtimeDependencies["gradle"]["platform-agnostic"]["archiveType"], + ) + + assert os.path.exists(gradle_path) + + dependency = runtimeDependencies["vscode-java"][platformId.value] + vscode_java_path = str( + PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"]) + ) + os.makedirs(vscode_java_path, exist_ok=True) + jre_home_path = str(PurePath(vscode_java_path, dependency["jre_home_path"])) + jre_path = str(PurePath(vscode_java_path, dependency["jre_path"])) + lombok_jar_path = str(PurePath(vscode_java_path, dependency["lombok_jar_path"])) + jdtls_launcher_jar_path = str(PurePath(vscode_java_path, dependency["jdtls_launcher_jar_path"])) + jdtls_readonly_config_path = str(PurePath(vscode_java_path, dependency["jdtls_readonly_config_path"])) + if not all( + [ + os.path.exists(vscode_java_path), + os.path.exists(jre_home_path), + os.path.exists(jre_path), + os.path.exists(lombok_jar_path), + os.path.exists(jdtls_launcher_jar_path), + os.path.exists(jdtls_readonly_config_path), + ] + ): + FileUtils.download_and_extract_archive( + logger, dependency["url"], vscode_java_path, dependency["archiveType"] + ) + + os.chmod(jre_path, stat.S_IEXEC) + + assert os.path.exists(vscode_java_path) + assert os.path.exists(jre_home_path) + assert os.path.exists(jre_path) + assert os.path.exists(lombok_jar_path) + assert os.path.exists(jdtls_launcher_jar_path) + assert os.path.exists(jdtls_readonly_config_path) + + dependency = runtimeDependencies["intellicode"]["platform-agnostic"] + intellicode_directory_path = str( + PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"]) + ) + os.makedirs(intellicode_directory_path, exist_ok=True) + intellicode_jar_path = str(PurePath(intellicode_directory_path, dependency["intellicode_jar_path"])) + intellisense_members_path = str(PurePath(intellicode_directory_path, dependency["intellisense_members_path"])) + if not all( + [ + os.path.exists(intellicode_directory_path), + os.path.exists(intellicode_jar_path), + os.path.exists(intellisense_members_path), + ] + ): + FileUtils.download_and_extract_archive( + logger, dependency["url"], intellicode_directory_path, dependency["archiveType"] + ) + + assert os.path.exists(intellicode_directory_path) + assert os.path.exists(intellicode_jar_path) + assert os.path.exists(intellisense_members_path) + + return RuntimeDependencyPaths( + gradle_path=gradle_path, + lombok_jar_path=lombok_jar_path, + jre_path=jre_path, + jre_home_path=jre_home_path, + jdtls_launcher_jar_path=jdtls_launcher_jar_path, + jdtls_readonly_config_path=jdtls_readonly_config_path, + intellicode_jar_path=intellicode_jar_path, + intellisense_members_path=intellisense_members_path, + ) + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize parameters for the EclipseJDTLS server. + """ + # Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available + with open(str(PurePath(os.path.dirname(__file__), "initialize_params.json")), "r", encoding="utf-8") as f: + d: InitializeParams = json.load(f) + + del d["_description"] + + if not os.path.isabs(repository_absolute_path): + repository_absolute_path = os.path.abspath(repository_absolute_path) + + assert d["processId"] == "os.getpid()" + d["processId"] = os.getpid() + + assert d["rootPath"] == "repository_absolute_path" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "pathlib.Path(repository_absolute_path).as_uri()" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["initializationOptions"]["workspaceFolders"] == "[pathlib.Path(repository_absolute_path).as_uri()]" + d["initializationOptions"]["workspaceFolders"] = [pathlib.Path(repository_absolute_path).as_uri()] + + assert ( + d["workspaceFolders"] + == '[\n {\n "uri": pathlib.Path(repository_absolute_path).as_uri(),\n "name": os.path.basename(repository_absolute_path),\n }\n ]' + ) + d["workspaceFolders"] = [ + { + "uri": pathlib.Path(repository_absolute_path).as_uri(), + "name": os.path.basename(repository_absolute_path), + } + ] + + assert d["initializationOptions"]["bundles"] == ["intellicode-core.jar"] + bundles = [self.runtime_dependency_paths.intellicode_jar_path] + d["initializationOptions"]["bundles"] = bundles + + assert d["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] == [ + {"name": "JavaSE-21", "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", "default": True} + ] + d["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ + {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} + ] + + for runtime in d["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: + assert "name" in runtime + assert "path" in runtime + assert os.path.exists( + runtime["path"] + ), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" + + assert d["initializationOptions"]["settings"]["java"]["import"]["gradle"]["home"] == "abs(static/gradle-7.3.3)" + d["initializationOptions"]["settings"]["java"]["import"]["gradle"][ + "home" + ] = self.runtime_dependency_paths.gradle_path + + d["initializationOptions"]["settings"]["java"]["import"]["gradle"]["java"][ + "home" + ] = self.runtime_dependency_paths.jre_path + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["EclipseJDTLS"]: + """ + Starts the Eclipse JDTLS Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + ``` + """ + + async def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "textDocument/completion": + assert registration["registerOptions"]["resolveProvider"] == True + assert registration["registerOptions"]["triggerCharacters"] == [ + ".", + "@", + "#", + "*", + " ", + ] + self.completions_available.set() + if registration["method"] == "workspace/executeCommand": + if "java.intellicode.enable" in registration["registerOptions"]["commands"]: + self.intellicode_enable_command_available.set() + return + + async def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + async def execute_client_command_handler(params): + assert params["command"] == "_java.reloadBundles.command" + assert params["arguments"] == [] + return [] + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + async def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + async with super().start_server(): + self.logger.log("Starting EclipseJDTLS server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" not in init_response["capabilities"] + assert "executeCommandProvider" not in init_response["capabilities"] + + self.server.notify.initialized({}) + + self.server.notify.workspace_did_change_configuration( + {"settings": initialize_params["initializationOptions"]["settings"]} + ) + + await self.intellicode_enable_command_available.wait() + + java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path + assert os.path.exists(java_intellisense_members_path) + intellicode_enable_result = await self.server.send.execute_command( + { + "command": "java.intellicode.enable", + "arguments": [True, java_intellisense_members_path], + } + ) + assert intellicode_enable_result + + # TODO: Add comments about why we wait here, and how this can be optimized + await self.service_ready_event.wait() + + yield self diff --git a/src/solidlsp/language_servers/eclipse_jdtls/initialize_params.json b/src/solidlsp/language_servers/eclipse_jdtls/initialize_params.json new file mode 100644 index 00000000..b65b7b62 --- /dev/null +++ b/src/solidlsp/language_servers/eclipse_jdtls/initialize_params.json @@ -0,0 +1,849 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "Visual Studio Code - Insiders", + "version": "1.77.0-insider" + }, + "locale": "en", + "rootPath": "repository_absolute_path", + "rootUri": "pathlib.Path(repository_absolute_path).as_uri()", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "configuration": true, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": false, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": false, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": true + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "window": { + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": true + } + }, + "showDocument": { + "support": true + }, + "workDoneProgress": true + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0" + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + } + }, + "initializationOptions": { + "bundles": [ + "intellicode-core.jar" + ], + "workspaceFolders": "[pathlib.Path(repository_absolute_path).as_uri()]", + "settings": { + "java": { + "home": null, + "jdt": { + "ls": { + "java": { + "home": null + }, + "vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx1G -Xms100m -Xlog:disable", + "lombokSupport": { + "enabled": true + }, + "protobufSupport": { + "enabled": true + }, + "androidSupport": { + "enabled": true + } + } + }, + "errors": { + "incompleteClasspath": { + "severity": "error" + } + }, + "configuration": { + "checkProjectSettingsExclusions": false, + "updateBuildConfiguration": "interactive", + "maven": { + "userSettings": null, + "globalSettings": null, + "notCoveredPluginExecutionSeverity": "warning", + "defaultMojoExecutionAction": "ignore" + }, + "workspaceCacheLimit": 90, + "runtimes": [ + { + "name": "JavaSE-21", + "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", + "default": true + } + ] + }, + "trace": { + "server": "verbose" + }, + "import": { + "maven": { + "enabled": true, + "offline": { + "enabled": false + }, + "disableTestClasspathFlag": false + }, + "gradle": { + "enabled": true, + "wrapper": { + "enabled": true + }, + "version": null, + "home": "abs(static/gradle-7.3.3)", + "java": { + "home": "abs(static/launch_jres/21.0.7-linux-x86_64)" + }, + "offline": { + "enabled": false + }, + "arguments": null, + "jvmArguments": null, + "user": { + "home": null + }, + "annotationProcessing": { + "enabled": true + } + }, + "exclusions": [ + "**/node_modules/**", + "**/.metadata/**", + "**/archetype-resources/**", + "**/META-INF/maven/**" + ], + "generatesMetadataFilesAtProjectRoot": false + }, + "maven": { + "downloadSources": true, + "updateSnapshots": true + }, + "eclipse": { + "downloadSources": true + }, + "referencesCodeLens": { + "enabled": true + }, + "signatureHelp": { + "enabled": true, + "description": { + "enabled": true + } + }, + "implementationsCodeLens": { + "enabled": true + }, + "format": { + "enabled": true, + "settings": { + "url": null, + "profile": null + }, + "comments": { + "enabled": true + }, + "onType": { + "enabled": true + }, + "insertSpaces": true, + "tabSize": 4 + }, + "saveActions": { + "organizeImports": false + }, + "project": { + "referencedLibraries": [ + "lib/**/*.jar" + ], + "importOnFirstTimeStartup": "automatic", + "importHint": true, + "resourceFilters": [ + "node_modules", + "\\.git" + ], + "encoding": "ignore", + "exportJar": { + "targetPath": "${workspaceFolder}/${workspaceFolderBasename}.jar" + } + }, + "contentProvider": { + "preferred": null + }, + "autobuild": { + "enabled": true + }, + "maxConcurrentBuilds": 1, + "recommendations": { + "dependency": { + "analytics": { + "show": true + } + } + }, + "completion": { + "maxResults": 0, + "enabled": true, + "guessMethodArguments": true, + "favoriteStaticMembers": [ + "org.junit.Assert.*", + "org.junit.Assume.*", + "org.junit.jupiter.api.Assertions.*", + "org.junit.jupiter.api.Assumptions.*", + "org.junit.jupiter.api.DynamicContainer.*", + "org.junit.jupiter.api.DynamicTest.*", + "org.mockito.Mockito.*", + "org.mockito.ArgumentMatchers.*", + "org.mockito.Answers.*" + ], + "filteredTypes": [ + "java.awt.*", + "com.sun.*", + "sun.*", + "jdk.*", + "org.graalvm.*", + "io.micrometer.shaded.*" + ], + "importOrder": [ + "#", + "java", + "javax", + "org", + "com", + "" + ], + "postfix": { + "enabled": false + }, + "matchCase": "off" + }, + "foldingRange": { + "enabled": true + }, + "progressReports": { + "enabled": false + }, + "codeGeneration": { + "hashCodeEquals": { + "useJava7Objects": false, + "useInstanceof": false + }, + "useBlocks": false, + "generateComments": false, + "toString": { + "template": "${object.className} [${member.name()}=${member.value}, ${otherMembers}]", + "codeStyle": "STRING_CONCATENATION", + "skipNullValues": false, + "listArrayContents": true, + "limitElements": 0 + }, + "insertionLocation": "afterCursor" + }, + "selectionRange": { + "enabled": true + }, + "showBuildStatusOnStart": { + "enabled": "notification" + }, + "server": { + "launchMode": "Standard" + }, + "sources": { + "organizeImports": { + "starThreshold": 99, + "staticStarThreshold": 99 + } + }, + "imports": { + "gradle": { + "wrapper": { + "checksums": [] + } + } + }, + "templates": { + "fileHeader": [], + "typeComment": [] + }, + "references": { + "includeAccessors": true, + "includeDecompiledSources": true + }, + "typeHierarchy": { + "lazyLoad": false + }, + "settings": { + "url": null + }, + "symbols": { + "includeSourceMethodDeclarations": false + }, + "quickfix": { + "showAt": "line" + }, + "inlayHints": { + "parameterNames": { + "enabled": "literals", + "exclusions": [] + } + }, + "codeAction": { + "sortMembers": { + "avoidVolatileChanges": true + } + }, + "compile": { + "nullAnalysis": { + "nonnull": [ + "javax.annotation.Nonnull", + "org.eclipse.jdt.annotation.NonNull", + "org.springframework.lang.NonNull" + ], + "nullable": [ + "javax.annotation.Nullable", + "org.eclipse.jdt.annotation.Nullable", + "org.springframework.lang.Nullable" + ], + "mode": "automatic" + } + }, + "cleanup": { + "actionsOnSave": [] + }, + "sharedIndexes": { + "enabled": "auto", + "location": "" + }, + "refactoring": { + "extract": { + "interface": { + "replace": true + } + } + }, + "debug": { + "logLevel": "verbose", + "settings": { + "showHex": false, + "showStaticVariables": false, + "showQualifiedNames": false, + "showLogicalStructure": true, + "showToString": true, + "maxStringLength": 0, + "numericPrecision": 0, + "hotCodeReplace": "manual", + "enableRunDebugCodeLens": true, + "forceBuildBeforeLaunch": true, + "onBuildFailureProceed": false, + "console": "integratedTerminal", + "exceptionBreakpoint": { + "skipClasses": [] + }, + "stepping": { + "skipClasses": [], + "skipSynthetics": false, + "skipStaticInitializers": false, + "skipConstructors": false + }, + "jdwp": { + "limitOfVariablesPerJdwpRequest": 100, + "requestTimeout": 3000, + "async": "auto" + }, + "vmArgs": "" + } + }, + "silentNotification": false, + "dependency": { + "showMembers": false, + "syncWithFolderExplorer": true, + "autoRefresh": true, + "refreshDelay": 2000, + "packagePresentation": "flat" + }, + "help": { + "firstView": "auto", + "showReleaseNotes": true, + "collectErrorLog": false + }, + "test": { + "defaultConfig": "", + "config": {} + } + } + }, + "extendedClientCapabilities": { + "progressReportProvider": false, + "classFileContentsSupport": true, + "overrideMethodsPromptSupport": true, + "hashCodeEqualsPromptSupport": true, + "advancedOrganizeImportsSupport": true, + "generateToStringPromptSupport": true, + "advancedGenerateAccessorsSupport": true, + "generateConstructorsPromptSupport": true, + "generateDelegateMethodsPromptSupport": true, + "advancedExtractRefactoringSupport": true, + "inferSelectionSupport": [ + "extractMethod", + "extractVariable", + "extractField" + ], + "moveRefactoringSupport": true, + "clientHoverProvider": true, + "clientDocumentSymbolProvider": true, + "gradleChecksumWrapperPromptSupport": true, + "resolveAdditionalTextEditsSupport": true, + "advancedIntroduceParameterRefactoringSupport": true, + "actionableRuntimeNotificationSupport": true, + "shouldLanguageServerExitOnShutdown": true, + "onCompletionItemSelectedCommand": "editor.action.triggerParameterHints", + "extractInterfaceSupport": true, + "advancedUpgradeGradleSupport": true + }, + "triggerFiles": [] + }, + "trace": "verbose", + "workspaceFolders": "[\n {\n \"uri\": pathlib.Path(repository_absolute_path).as_uri(),\n \"name\": os.path.basename(repository_absolute_path),\n }\n ]" +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/eclipse_jdtls/runtime_dependencies.json b/src/solidlsp/language_servers/eclipse_jdtls/runtime_dependencies.json new file mode 100644 index 00000000..1b000405 --- /dev/null +++ b/src/solidlsp/language_servers/eclipse_jdtls/runtime_dependencies.json @@ -0,0 +1,72 @@ +{ + "_description": "This file lists the runtime dependencies for the Java Language Server", + "gradle": { + "platform-agnostic": { + "url": "https://services.gradle.org/distributions/gradle-7.3.3-bin.zip", + "archiveType": "zip", + "relative_extraction_path": "." + } + }, + "vscode-java": { + "darwin-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java" + }, + "osx-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-macosx-aarch64", + "jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_mac_arm" + }, + "osx-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-macosx-x86_64", + "jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_mac" + }, + "linux-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java" + }, + "linux-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-linux-x86_64", + "jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_linux" + }, + "win-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", + "archiveType": "zip", + "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-win32-x86_64", + "jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_win" + } + }, + "intellicode": { + "platform-agnostic": { + "url": "https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", + "alternate_url": "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/1.2.30/vspackage", + "archiveType": "zip", + "relative_extraction_path": "intellicode", + "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", + "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members" + } + } +} diff --git a/src/solidlsp/language_servers/gopls/gopls.py b/src/solidlsp/language_servers/gopls/gopls.py new file mode 100644 index 00000000..0e5574a0 --- /dev/null +++ b/src/solidlsp/language_servers/gopls/gopls.py @@ -0,0 +1,154 @@ +import asyncio +import json +import logging +import os +import pathlib +import subprocess +from contextlib import asynccontextmanager +from typing import AsyncIterator, List + +from overrides import override + +from multilspy import multilspy_types +from multilspy.lsp_protocol_handler import lsp_types +from multilspy.multilspy_exceptions import MultilspyException +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import Error, ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig + + +class Gopls(LanguageServer): + """ + Provides Go specific instantiation of the LanguageServer class using gopls. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Go projects, we should ignore: + # - vendor: third-party dependencies vendored into the project + # - node_modules: if the project has JavaScript components + # - dist/build: common output directories + return super().is_ignored_dirname(dirname) or dirname in ["vendor", "node_modules", "dist", "build"] + + @staticmethod + def _get_go_version(): + """Get the installed Go version or None if not found.""" + try: + result = subprocess.run(['go', 'version'], capture_output=True, text=True) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _get_gopls_version(): + """Get the installed gopls version or None if not found.""" + try: + result = subprocess.run(['gopls', 'version'], capture_output=True, text=True) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @classmethod + def setup_runtime_dependency(cls): + """ + Check if required Go runtime dependencies are available. + Raises RuntimeError with helpful message if dependencies are missing. + """ + go_version = cls._get_go_version() + if not go_version: + raise RuntimeError("Go is not installed. Please install Go from https://golang.org/doc/install and make sure it is added to your PATH.") + + gopls_version = cls._get_gopls_version() + if not gopls_version: + raise RuntimeError( + "Found a Go version but gopls is not installed.\n" + "Please install gopls as described in https://pkg.go.dev/golang.org/x/tools/gopls#section-readme\n\n" + "After installation, make sure it is added to your PATH (it might be installed in a different location than Go)." + ) + + return True + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + self.setup_runtime_dependency() + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), + "go", + ) + self.server_ready = asyncio.Event() + self.request_id = 0 + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the TypeScript Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r", encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["Gopls"]: + """Start gopls server process""" + async def register_capability_handler(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + async def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + async with super().start_server(): + self.logger.log("Starting gopls server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.completions_available.set() + + # gopls server is typically ready immediately after initialization + self.server_ready.set() + await self.server_ready.wait() + + yield self diff --git a/src/solidlsp/language_servers/gopls/initialize_params.json b/src/solidlsp/language_servers/gopls/initialize_params.json new file mode 100644 index 00000000..40592e1a --- /dev/null +++ b/src/solidlsp/language_servers/gopls/initialize_params.json @@ -0,0 +1,46 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": true, + "dynamicRegistration": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true + } + }, + "definition": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "hierarchicalDocumentSymbolSupport": true, + "symbolKind": { + "valueSet": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 + ] + } + } + }, + "workspace": { + "workspaceFolders": true, + "didChangeConfiguration": { + "dynamicRegistration": true + } + } + }, + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/intelephense/initialize_params.json b/src/solidlsp/language_servers/intelephense/initialize_params.json new file mode 100644 index 00000000..4330560e --- /dev/null +++ b/src/solidlsp/language_servers/intelephense/initialize_params.json @@ -0,0 +1,36 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": true, + "dynamicRegistration": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true + } + }, + "definition": { + "dynamicRegistration": true + } + }, + "workspace": { + "workspaceFolders": true, + "didChangeConfiguration": { + "dynamicRegistration": true + } + } + }, + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/intelephense/intelephense.py b/src/solidlsp/language_servers/intelephense/intelephense.py new file mode 100644 index 00000000..d2ad9117 --- /dev/null +++ b/src/solidlsp/language_servers/intelephense/intelephense.py @@ -0,0 +1,200 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using Intelephense. +""" + +import asyncio +import json +import shutil +import logging +import os +import subprocess +import pathlib +from contextlib import asynccontextmanager +from time import sleep +from typing import AsyncIterator + +from overrides import override + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import DefinitionParams, InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import PlatformUtils, PlatformId + +class Intelephense(LanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using Intelephense. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For PHP projects, we should ignore: + # - vendor: third-party dependencies managed by Composer + # - node_modules: if the project has JavaScript components + # - cache: commonly used for caching + return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "cache"] + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for Intelephense. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + PlatformId.WIN_arm64, + ] + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy PHP at the moment" + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r", encoding="utf-8") as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d.get("runtimeDependencies", []) + intelephense_ls_dir = os.path.join(os.path.dirname(__file__), "static", "php-lsp") + + # Verify both node and npm are installed + is_node_installed = shutil.which('node') is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which('npm') is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + # Install intelephense if not already installed + if not os.path.exists(intelephense_ls_dir): + os.makedirs(intelephense_ls_dir, exist_ok=True) + for dependency in runtime_dependencies: + # Windows doesn't support the 'user' parameter and doesn't have pwd module + if PlatformUtils.get_platform_id().value.startswith("win"): + subprocess.run( + dependency["command"], + shell=True, + check=True, + cwd=intelephense_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + else: + # On Unix-like systems, run as non-root user + import pwd + user = pwd.getpwuid(os.getuid()).pw_name + subprocess.run( + dependency["command"], + shell=True, + check=True, + user=user, + cwd=intelephense_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") + assert os.path.exists(intelephense_executable_path), "intelephense executable not found. Please install intelephense and try again." + + return f"{intelephense_executable_path} --stdio" + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + # Setup runtime dependencies before initializing + intelephense_cmd = self.setup_runtime_dependencies(logger, config) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), + "php" + ) + self.server_ready = asyncio.Event() + self.request_id = 0 + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the TypeScript Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r", encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["Intelephense"]: + """Start Intelephense server process""" + async def register_capability_handler(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + async def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + async with super().start_server(): + self.logger.log("Starting Intelephense server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log( + "After sent initialize params", + logging.INFO, + ) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.completions_available.set() + + # Intelephense server is typically ready immediately after initialization + self.server_ready.set() + await self.server_ready.wait() + + yield self + + @override + # For some reason, the LS may need longer to process this, so we just retry + async def _send_references_request(self, relative_file_path: str, line: int, column: int): + # TODO: The LS doesn't return references contained in other files if it doesn't sleep. This is + # despite the LS having processed requests already. I don't know what causes this, but sleeping + # one second helps. It may be that sleeping only once is enough but that's hard to reliably test. + # May be related to the time it takes to read the files or something like that. + # The sleeping doesn't seem to be needed on all systems + sleep(1) + return await super()._send_references_request(relative_file_path, line, column) + + @override + async def _send_definition_request(self, definition_params: DefinitionParams): + # TODO: same as above, also only a problem if the definition is in another file + sleep(1) + return await super()._send_definition_request(definition_params) diff --git a/src/solidlsp/language_servers/intelephense/runtime_dependencies.json b/src/solidlsp/language_servers/intelephense/runtime_dependencies.json new file mode 100644 index 00000000..e3a6d7e1 --- /dev/null +++ b/src/solidlsp/language_servers/intelephense/runtime_dependencies.json @@ -0,0 +1,10 @@ +{ + "_description": "Used to download the runtime dependencies for running intelephense. Obtained from https://www.npmjs.com/package/intelephense", + "runtimeDependencies": [ + { + "id": "intelephense", + "description": "Intelephense package for Linux, OSX, and Windows. Both x64 and arm64 are supported.", + "command": "npm install --prefix ./ intelephense@1.14.4" + } + ] +} diff --git a/src/solidlsp/language_servers/jedi_language_server/initialize_params.json b/src/solidlsp/language_servers/jedi_language_server/initialize_params.json new file mode 100644 index 00000000..5a04872c --- /dev/null +++ b/src/solidlsp/language_servers/jedi_language_server/initialize_params.json @@ -0,0 +1,905 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "Visual Studio Code - Insiders", + "version": "1.81.0-insider" + }, + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "configuration": true, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": true, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": true, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": false + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "window": { + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": true + } + }, + "showDocument": { + "support": true + }, + "workDoneProgress": true + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0", + "allowedTags": [ + "ul", + "li", + "p", + "code", + "blockquote", + "ol", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "em", + "pre", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "div", + "del", + "a", + "strong", + "br", + "img", + "span" + ] + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + }, + "experimental": { + "snippetTextEdit": true, + "codeActionGroup": true, + "hoverActions": true, + "serverStatusNotification": true, + "colorDiagnosticOutput": true, + "openServerLogs": true, + "commands": { + "commands": [ + "rust-analyzer.runSingle", + "rust-analyzer.debugSingle", + "rust-analyzer.showReferences", + "rust-analyzer.gotoLocation", + "editor.action.triggerParameterHints" + ] + } + } + }, + "initializationOptions": { + "cargoRunner": null, + "runnables": { + "extraEnv": null, + "problemMatcher": [ + "$rustc" + ], + "command": null, + "extraArgs": [] + }, + "server": { + "path": null, + "extraEnv": null + }, + "trace": { + "server": "verbose", + "extension": true + }, + "debug": { + "engine": "auto", + "sourceFileMap": { + "/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust" + }, + "openDebugPane": false, + "engineSettings": {} + }, + "restartServerOnConfigChange": false, + "typing": { + "continueCommentsOnNewline": true, + "autoClosingAngleBrackets": { + "enable": false + } + }, + "diagnostics": { + "previewRustcOutput": false, + "useRustcErrorCode": false, + "disabled": [], + "enable": true, + "experimental": { + "enable": false + }, + "remapPrefix": {}, + "warningsAsHint": [], + "warningsAsInfo": [] + }, + "discoverProjectCommand": null, + "showUnlinkedFileNotification": true, + "showDependenciesExplorer": true, + "assist": { + "emitMustUse": false, + "expressionFillDefault": "todo" + }, + "cachePriming": { + "enable": true, + "numThreads": 0 + }, + "cargo": { + "autoreload": true, + "buildScripts": { + "enable": true, + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "overrideCommand": null, + "useRustcWrapper": true + }, + "cfgs": {}, + "extraArgs": [], + "extraEnv": {}, + "features": [], + "noDefaultFeatures": false, + "sysroot": "discover", + "sysrootSrc": null, + "target": null, + "unsetTest": [ + "core" + ] + }, + "checkOnSave": true, + "check": { + "allTargets": true, + "command": "check", + "extraArgs": [], + "extraEnv": {}, + "features": null, + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "noDefaultFeatures": null, + "overrideCommand": null, + "targets": null + }, + "completion": { + "autoimport": { + "enable": true + }, + "autoself": { + "enable": true + }, + "callable": { + "snippets": "fill_arguments" + }, + "limit": null, + "postfix": { + "enable": true + }, + "privateEditable": { + "enable": false + }, + "snippets": { + "custom": { + "Arc::new": { + "postfix": "arc", + "body": "Arc::new(${receiver})", + "requires": "std::sync::Arc", + "description": "Put the expression into an `Arc`", + "scope": "expr" + }, + "Rc::new": { + "postfix": "rc", + "body": "Rc::new(${receiver})", + "requires": "std::rc::Rc", + "description": "Put the expression into an `Rc`", + "scope": "expr" + }, + "Box::pin": { + "postfix": "pinbox", + "body": "Box::pin(${receiver})", + "requires": "std::boxed::Box", + "description": "Put the expression into a pinned `Box`", + "scope": "expr" + }, + "Ok": { + "postfix": "ok", + "body": "Ok(${receiver})", + "description": "Wrap the expression in a `Result::Ok`", + "scope": "expr" + }, + "Err": { + "postfix": "err", + "body": "Err(${receiver})", + "description": "Wrap the expression in a `Result::Err`", + "scope": "expr" + }, + "Some": { + "postfix": "some", + "body": "Some(${receiver})", + "description": "Wrap the expression in an `Option::Some`", + "scope": "expr" + } + } + } + }, + "files": { + "excludeDirs": [], + "watcher": "client" + }, + "highlightRelated": { + "breakPoints": { + "enable": true + }, + "closureCaptures": { + "enable": true + }, + "exitPoints": { + "enable": true + }, + "references": { + "enable": true + }, + "yieldPoints": { + "enable": true + } + }, + "hover": { + "actions": { + "debug": { + "enable": true + }, + "enable": true, + "gotoTypeDef": { + "enable": true + }, + "implementations": { + "enable": true + }, + "references": { + "enable": false + }, + "run": { + "enable": true + } + }, + "documentation": { + "enable": true, + "keywords": { + "enable": true + } + }, + "links": { + "enable": true + }, + "memoryLayout": { + "alignment": "hexadecimal", + "enable": true, + "niches": false, + "offset": "hexadecimal", + "size": "both" + } + }, + "imports": { + "granularity": { + "enforce": false, + "group": "crate" + }, + "group": { + "enable": true + }, + "merge": { + "glob": true + }, + "prefer": { + "no": { + "std": false + } + }, + "prefix": "plain" + }, + "inlayHints": { + "bindingModeHints": { + "enable": false + }, + "chainingHints": { + "enable": true + }, + "closingBraceHints": { + "enable": true, + "minLines": 25 + }, + "closureCaptureHints": { + "enable": false + }, + "closureReturnTypeHints": { + "enable": "never" + }, + "closureStyle": "impl_fn", + "discriminantHints": { + "enable": "never" + }, + "expressionAdjustmentHints": { + "enable": "never", + "hideOutsideUnsafe": false, + "mode": "prefix" + }, + "lifetimeElisionHints": { + "enable": "never", + "useParameterNames": false + }, + "maxLength": 25, + "parameterHints": { + "enable": true + }, + "reborrowHints": { + "enable": "never" + }, + "renderColons": true, + "typeHints": { + "enable": true, + "hideClosureInitialization": false, + "hideNamedConstructor": false + } + }, + "interpret": { + "tests": false + }, + "joinLines": { + "joinAssignments": true, + "joinElseIf": true, + "removeTrailingComma": true, + "unwrapTrivialBlock": true + }, + "lens": { + "debug": { + "enable": true + }, + "enable": true, + "forceCustomCommands": true, + "implementations": { + "enable": true + }, + "location": "above_name", + "references": { + "adt": { + "enable": false + }, + "enumVariant": { + "enable": false + }, + "method": { + "enable": false + }, + "trait": { + "enable": false + } + }, + "run": { + "enable": true + } + }, + "linkedProjects": [], + "lru": { + "capacity": null, + "query": { + "capacities": {} + } + }, + "notifications": { + "cargoTomlNotFound": true + }, + "numThreads": null, + "procMacro": { + "attributes": { + "enable": true + }, + "enable": true, + "ignored": {}, + "server": null + }, + "references": { + "excludeImports": false + }, + "rustc": { + "source": null + }, + "rustfmt": { + "extraArgs": [], + "overrideCommand": null, + "rangeFormatting": { + "enable": false + } + }, + "semanticHighlighting": { + "doc": { + "comment": { + "inject": { + "enable": true + } + } + }, + "nonStandardTokens": true, + "operator": { + "enable": true, + "specialization": { + "enable": false + } + }, + "punctuation": { + "enable": false, + "separate": { + "macro": { + "bang": false + } + }, + "specialization": { + "enable": false + } + }, + "strings": { + "enable": true + } + }, + "signatureInfo": { + "detail": "full", + "documentation": { + "enable": true + } + }, + "workspace": { + "symbols": { + "maxSymbols": 0 + } + } + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/jedi_language_server/jedi_server.py b/src/solidlsp/language_servers/jedi_language_server/jedi_server.py new file mode 100644 index 00000000..28c9d91d --- /dev/null +++ b/src/solidlsp/language_servers/jedi_language_server/jedi_server.py @@ -0,0 +1,123 @@ +""" +Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. +""" + +import json +import logging +import os +import pathlib +from contextlib import asynccontextmanager +from typing import AsyncIterator, Tuple + +from overrides import override + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig + + +class JediServer(LanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a JediServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), + "python", + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Jedi Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r", encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["JediServer"]: + """ + Starts the JEDI Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + ``` + """ + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def check_experimental_status(params): + if params["quiescent"] == True: + self.completions_available.set() + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + async with super().start_server(): + self.logger.log("Starting jedi-language-server server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": [".", "'", '"'], + "resolveProvider": True, + } + + self.server.notify.initialized({}) + + yield self diff --git a/src/solidlsp/language_servers/kotlin_language_server/initialize_params.json b/src/solidlsp/language_servers/kotlin_language_server/initialize_params.json new file mode 100644 index 00000000..a7b2ec0a --- /dev/null +++ b/src/solidlsp/language_servers/kotlin_language_server/initialize_params.json @@ -0,0 +1,521 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "Multilspy Kotlin Client", + "version": "1.0.0" + }, + "locale": "en", + "rootPath": "repository_absolute_path", + "rootUri": "pathlib.Path(repository_absolute_path).as_uri()", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "configuration": true, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": false, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": false, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": true + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "window": { + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": true + } + }, + "showDocument": { + "support": true + }, + "workDoneProgress": true + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0" + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + } + }, + "initializationOptions": { + "workspaceFolders": "[pathlib.Path(repository_absolute_path).as_uri()]", + "storagePath": null, + "codegen": { + "enabled": false + }, + "compiler": { + "jvm": { + "target": "default" + } + }, + "completion": { + "snippets": { + "enabled": true + } + }, + "diagnostics": { + "enabled": true, + "level": 4, + "debounceTime": 250 + }, + "scripts": { + "enabled": true, + "buildScriptsEnabled": true + }, + "indexing": { + "enabled": true + }, + "externalSources": { + "useKlsScheme": false, + "autoConvertToKotlin": false + }, + "inlayHints": { + "typeHints": false, + "parameterHints": false, + "chainedHints": false + }, + "formatting": { + "formatter": "ktfmt", + "ktfmt": { + "style": "google", + "indent": 4, + "maxWidth": 100, + "continuationIndent": 8, + "removeUnusedImports": true + } + } + }, + "trace": "verbose", + "workspaceFolders": "[\n {\n \"uri\": pathlib.Path(repository_absolute_path).as_uri(),\n \"name\": os.path.basename(repository_absolute_path),\n }\n ]" +} diff --git a/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py new file mode 100644 index 00000000..ca4e8666 --- /dev/null +++ b/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py @@ -0,0 +1,227 @@ +""" +Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. +""" + +import asyncio +import dataclasses +import json +import logging +import os +import stat +import pathlib +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils + + +@dataclasses.dataclass +class KotlinRuntimeDependencyPaths: + """ + Stores the paths to the runtime dependencies of Kotlin Language Server + """ + java_path: str + java_home_path: str + kotlin_executable_path: str + + +class KotlinLanguageServer(LanguageServer): + """ + Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a Kotlin Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + runtime_dependency_paths = self.setup_runtime_dependencies(logger, config) + self.runtime_dependency_paths = runtime_dependency_paths + + # Create command to execute the Kotlin Language Server script + cmd = f'"{self.runtime_dependency_paths.kotlin_executable_path}"' + + # Set environment variables including JAVA_HOME + proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), + "kotlin", + ) + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> KotlinRuntimeDependencyPaths: + """ + Setup runtime dependencies for Kotlin Language Server. + """ + platform_id = PlatformUtils.get_platform_id() + + # Verify platform support + assert platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-"), "Only Windows, Linux and macOS platforms are supported for Kotlin in multilspy at the moment" + + # Load dependency information + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r", encoding="utf-8") as f: + d = json.load(f) + del d["_description"] + + kotlin_dependency = d["runtimeDependency"] + java_dependency = d["java"][platform_id.value] + + # Setup paths for dependencies + static_dir = os.path.join(os.path.dirname(__file__), "static") + os.makedirs(static_dir, exist_ok=True) + + # Setup Java paths + java_dir = os.path.join(static_dir, "java") + os.makedirs(java_dir, exist_ok=True) + + java_home_path = os.path.join(java_dir, java_dependency["java_home_path"]) + java_path = os.path.join(java_dir, java_dependency["java_path"]) + + # Download and extract Java if not exists + if not os.path.exists(java_path): + logger.log(f"Downloading Java for {platform_id.value}...", logging.INFO) + FileUtils.download_and_extract_archive( + logger, java_dependency["url"], java_dir, java_dependency["archiveType"] + ) + # Make Java executable + if not platform_id.value.startswith("win-"): + os.chmod(java_path, 0o755) + + assert os.path.exists(java_path), f"Java executable not found at {java_path}" + + # Setup Kotlin Language Server paths + kotlin_ls_dir = os.path.join(static_dir, "server") + + # Get platform-specific executable script path + if platform_id.value.startswith("win-"): + kotlin_script = os.path.join(kotlin_ls_dir, "bin", "kotlin-language-server.bat") + else: + kotlin_script = os.path.join(kotlin_ls_dir, "bin", "kotlin-language-server") + + # Download and extract Kotlin Language Server if script doesn't exist + if not os.path.exists(kotlin_script): + logger.log("Downloading Kotlin Language Server...", logging.INFO) + FileUtils.download_and_extract_archive( + logger, kotlin_dependency["url"], static_dir, kotlin_dependency["archiveType"] + ) + + # Make script executable on Unix platforms + if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): + os.chmod(kotlin_script, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + + # Use script file + if os.path.exists(kotlin_script): + kotlin_executable_path = kotlin_script + logger.log(f"Using Kotlin Language Server script at {kotlin_script}", logging.INFO) + else: + raise FileNotFoundError(f"Kotlin Language Server script not found at {kotlin_script}") + + return KotlinRuntimeDependencyPaths( + java_path=java_path, + java_home_path=java_home_path, + kotlin_executable_path=kotlin_executable_path + ) + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Kotlin Language Server. + """ + with open(str(pathlib.PurePath(os.path.dirname(__file__), "initialize_params.json")), "r", encoding="utf-8") as f: + d: InitializeParams = json.load(f) + + del d["_description"] + + if not os.path.isabs(repository_absolute_path): + repository_absolute_path = os.path.abspath(repository_absolute_path) + + assert d["processId"] == "os.getpid()" + d["processId"] = os.getpid() + + assert d["rootPath"] == "repository_absolute_path" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "pathlib.Path(repository_absolute_path).as_uri()" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["initializationOptions"]["workspaceFolders"] == "[pathlib.Path(repository_absolute_path).as_uri()]" + d["initializationOptions"]["workspaceFolders"] = [pathlib.Path(repository_absolute_path).as_uri()] + + assert ( + d["workspaceFolders"] + == '[\n {\n "uri": pathlib.Path(repository_absolute_path).as_uri(),\n "name": os.path.basename(repository_absolute_path),\n }\n ]' + ) + d["workspaceFolders"] = [ + { + "uri": pathlib.Path(repository_absolute_path).as_uri(), + "name": os.path.basename(repository_absolute_path), + } + ] + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["KotlinLanguageServer"]: + """ + Starts the Kotlin Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + ``` + """ + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + async with super().start_server(): + self.logger.log("Starting Kotlin server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + assert "hoverProvider" in capabilities, "Server must support hover" + assert "completionProvider" in capabilities, "Server must support code completion" + assert "signatureHelpProvider" in capabilities, "Server must support signature help" + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" + assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" + + self.server.notify.initialized({}) + self.completions_available.set() + + yield self diff --git a/src/solidlsp/language_servers/kotlin_language_server/runtime_dependencies.json b/src/solidlsp/language_servers/kotlin_language_server/runtime_dependencies.json new file mode 100644 index 00000000..3568c9d4 --- /dev/null +++ b/src/solidlsp/language_servers/kotlin_language_server/runtime_dependencies.json @@ -0,0 +1,41 @@ +{ + "_description": "Used to download the runtime dependencies for Kotlin Language Server from https://github.com/fwcd/kotlin-language-server", + "runtimeDependency": { + "id": "KotlinLsp", + "description": "Kotlin Language Server", + "url": "https://github.com/fwcd/kotlin-language-server/releases/download/1.3.13/server.zip", + "archiveType": "zip" + }, + "java": { + "win-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-win32-x86_64", + "java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe" + }, + "linux-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-linux-x86_64", + "java_path": "extension/jre/21.0.7-linux-x86_64/bin/java" + }, + "linux-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-linux-aarch64", + "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java" + }, + "osx-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-macosx-x86_64", + "java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java" + }, + "osx-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-macosx-aarch64", + "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java" + } + } +} diff --git a/src/solidlsp/language_servers/omnisharp/initialize_params.json b/src/solidlsp/language_servers/omnisharp/initialize_params.json new file mode 100644 index 00000000..f9ee86c3 --- /dev/null +++ b/src/solidlsp/language_servers/omnisharp/initialize_params.json @@ -0,0 +1,631 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "Visual Studio Code - Insiders", + "version": "1.82.0-insider" + }, + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "configuration": false, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": true, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": true, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": false + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "window": { + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": true + } + }, + "showDocument": { + "support": true + }, + "workDoneProgress": true + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0", + "allowedTags": [ + "ul", + "li", + "p", + "code", + "blockquote", + "ol", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "em", + "pre", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "div", + "del", + "a", + "strong", + "br", + "img", + "span" + ] + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + }, + "experimental": { + "snippetTextEdit": true, + "codeActionGroup": true, + "hoverActions": true, + "serverStatusNotification": true, + "colorDiagnosticOutput": true, + "openServerLogs": true, + "commands": { + "commands": [ + "editor.action.triggerParameterHints" + ] + } + } + }, + "initializationOptions": { + "RoslynExtensionsOptions": { + "EnableDecompilationSupport": false, + "EnableAnalyzersSupport": true, + "EnableImportCompletion": true, + "EnableAsyncCompletion": false, + "DocumentAnalysisTimeoutMs": 30000, + "DiagnosticWorkersThreadCount": 18, + "AnalyzeOpenDocumentsOnly": true, + "InlayHintsOptions": { + "EnableForParameters": false, + "ForLiteralParameters": false, + "ForIndexerParameters": false, + "ForObjectCreationParameters": false, + "ForOtherParameters": false, + "SuppressForParametersThatDifferOnlyBySuffix": false, + "SuppressForParametersThatMatchMethodIntent": false, + "SuppressForParametersThatMatchArgumentName": false, + "EnableForTypes": false, + "ForImplicitVariableTypes": false, + "ForLambdaParameterTypes": false, + "ForImplicitObjectCreation": false + }, + "LocationPaths": null + }, + "FormattingOptions": { + "OrganizeImports": false, + "EnableEditorConfigSupport": true, + "NewLine": "\n", + "UseTabs": false, + "TabSize": 4, + "IndentationSize": 4, + "SpacingAfterMethodDeclarationName": false, + "SeparateImportDirectiveGroups": false, + "SpaceWithinMethodDeclarationParenthesis": false, + "SpaceBetweenEmptyMethodDeclarationParentheses": false, + "SpaceAfterMethodCallName": false, + "SpaceWithinMethodCallParentheses": false, + "SpaceBetweenEmptyMethodCallParentheses": false, + "SpaceAfterControlFlowStatementKeyword": true, + "SpaceWithinExpressionParentheses": false, + "SpaceWithinCastParentheses": false, + "SpaceWithinOtherParentheses": false, + "SpaceAfterCast": false, + "SpaceBeforeOpenSquareBracket": false, + "SpaceBetweenEmptySquareBrackets": false, + "SpaceWithinSquareBrackets": false, + "SpaceAfterColonInBaseTypeDeclaration": true, + "SpaceAfterComma": true, + "SpaceAfterDot": false, + "SpaceAfterSemicolonsInForStatement": true, + "SpaceBeforeColonInBaseTypeDeclaration": true, + "SpaceBeforeComma": false, + "SpaceBeforeDot": false, + "SpaceBeforeSemicolonsInForStatement": false, + "SpacingAroundBinaryOperator": "single", + "IndentBraces": false, + "IndentBlock": true, + "IndentSwitchSection": true, + "IndentSwitchCaseSection": true, + "IndentSwitchCaseSectionWhenBlock": true, + "LabelPositioning": "oneLess", + "WrappingPreserveSingleLine": true, + "WrappingKeepStatementsOnSingleLine": true, + "NewLinesForBracesInTypes": true, + "NewLinesForBracesInMethods": true, + "NewLinesForBracesInProperties": true, + "NewLinesForBracesInAccessors": true, + "NewLinesForBracesInAnonymousMethods": true, + "NewLinesForBracesInControlBlocks": true, + "NewLinesForBracesInAnonymousTypes": true, + "NewLinesForBracesInObjectCollectionArrayInitializers": true, + "NewLinesForBracesInLambdaExpressionBody": true, + "NewLineForElse": true, + "NewLineForCatch": true, + "NewLineForFinally": true, + "NewLineForMembersInObjectInit": true, + "NewLineForMembersInAnonymousTypes": true, + "NewLineForClausesInQuery": true + }, + "FileOptions": { + "SystemExcludeSearchPatterns": [ + "**/node_modules/**/*", + "**/bin/**/*", + "**/obj/**/*", + "**/.git/**/*", + "**/.git", + "**/.svn", + "**/.hg", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db" + ], + "ExcludeSearchPatterns": [] + }, + "RenameOptions": { + "RenameOverloads": false, + "RenameInStrings": false, + "RenameInComments": false + }, + "ImplementTypeOptions": { + "InsertionBehavior": 0, + "PropertyGenerationBehavior": 0 + }, + "DotNetCliOptions": { + "LocationPaths": null + }, + "Plugins": { + "LocationPaths": null + } + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/omnisharp/omnisharp.py b/src/solidlsp/language_servers/omnisharp/omnisharp.py new file mode 100644 index 00000000..df3cf0cd --- /dev/null +++ b/src/solidlsp/language_servers/omnisharp/omnisharp.py @@ -0,0 +1,406 @@ +""" +Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. +""" + +import asyncio +import json +import logging +import os +import pathlib +import stat +from contextlib import asynccontextmanager +from typing import AsyncIterator, Iterable + +from overrides import override + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_exceptions import MultilspyException +from multilspy.multilspy_utils import FileUtils, PlatformUtils, PlatformId, DotnetVersion + + +def breadth_first_file_scan(root) -> Iterable[str]: + """ + This function was obtained from https://stackoverflow.com/questions/49654234/is-there-a-breadth-first-search-option-available-in-os-walk-or-equivalent-py + It traverses the directory tree in breadth first order. + """ + dirs = [root] + # while we has dirs to scan + while len(dirs): + next_dirs = [] + for parent in dirs: + # scan each dir + for f in os.listdir(parent): + # if there is a dir, then save for next ittr + # if it is a file then yield it (we'll return later) + ff = os.path.join(parent, f) + if os.path.isdir(ff): + next_dirs.append(ff) + else: + yield ff + + # once we've done all the current dirs then + # we set up the next itter as the child dirs + # from the current itter. + dirs = next_dirs + + +def find_least_depth_sln_file(root_dir) -> str | None: + for filename in breadth_first_file_scan(root_dir): + if filename.endswith(".sln"): + return filename + return None + + +class OmniSharp(LanguageServer): + """ + Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates an OmniSharp instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + omnisharp_executable_path, dll_path = self.setupRuntimeDependencies(logger, config) + + slnfilename = find_least_depth_sln_file(repository_root_path) + if slnfilename is None: + logger.log("No *.sln file found in repository", logging.ERROR) + raise MultilspyException("No SLN file found in repository") + + cmd = " ".join( + [ + omnisharp_executable_path, + "-lsp", + "--encoding", + "ascii", + "-z", + "-s", + f'"{slnfilename}"', + "--hostPID", + str(os.getpid()), + "DotNet:enablePackageRestore=false", + "--loglevel", + "trace", + "--plugin", + dll_path, + "FileOptions:SystemExcludeSearchPatterns:0=**/.git", + "FileOptions:SystemExcludeSearchPatterns:1=**/.svn", + "FileOptions:SystemExcludeSearchPatterns:2=**/.hg", + "FileOptions:SystemExcludeSearchPatterns:3=**/CVS", + "FileOptions:SystemExcludeSearchPatterns:4=**/.DS_Store", + "FileOptions:SystemExcludeSearchPatterns:5=**/Thumbs.db", + "RoslynExtensionsOptions:EnableAnalyzersSupport=true", + "FormattingOptions:EnableEditorConfigSupport=true", + "RoslynExtensionsOptions:EnableImportCompletion=true", + "Sdk:IncludePrereleases=true", + "RoslynExtensionsOptions:AnalyzeOpenDocumentsOnly=true", + "formattingOptions:useTabs=false", + "formattingOptions:tabSize=4", + "formattingOptions:indentationSize=4", + ] + ) + super().__init__( + config, logger, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp" + ) + + self.definition_available = asyncio.Event() + self.references_available = asyncio.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj"] + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Omnisharp Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r", encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + def setupRuntimeDependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> tuple[str, str]: + """ + Setup runtime dependencies for OmniSharp. + """ + platform_id = PlatformUtils.get_platform_id() + dotnet_version = PlatformUtils.get_dotnet_version() + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r", encoding="utf-8") as f: + d = json.load(f) + del d["_description"] + + assert platform_id in [ + PlatformId.LINUX_x64, + PlatformId.WIN_x64, + ], "Only linux-x64 and win-x64 platform is supported for in multilspy at the moment" + assert dotnet_version in [ + DotnetVersion.V6, + DotnetVersion.V7, + DotnetVersion.V8 + ], "Only dotnet version 6 and 7 are supported in multilspy at the moment" + + # TODO: Do away with this assumption + # Currently, runtime binaries are not available for .Net 7 and .Net 8. Hence, we assume .Net 6 runtime binaries to be compatible with .Net 7, .Net 8 + if dotnet_version in [DotnetVersion.V7, DotnetVersion.V8]: + dotnet_version = DotnetVersion.V6 + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [ + dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value + ] + runtime_dependencies = [ + dependency + for dependency in runtime_dependencies + if not ("dotnet_version" in dependency) or dependency["dotnet_version"] == dotnet_version.value + ] + assert len(runtime_dependencies) == 2 + runtime_dependencies = { + runtime_dependencies[0]["id"]: runtime_dependencies[0], + runtime_dependencies[1]["id"]: runtime_dependencies[1], + } + + assert "OmniSharp" in runtime_dependencies + assert "RazorOmnisharp" in runtime_dependencies + + omnisharp_ls_dir = os.path.join(os.path.dirname(__file__), "static", "OmniSharp") + if not os.path.exists(omnisharp_ls_dir): + os.makedirs(omnisharp_ls_dir) + FileUtils.download_and_extract_archive( + logger, runtime_dependencies["OmniSharp"]["url"], omnisharp_ls_dir, "zip" + ) + omnisharp_executable_path = os.path.join(omnisharp_ls_dir, runtime_dependencies["OmniSharp"]["binaryName"]) + assert os.path.exists(omnisharp_executable_path) + os.chmod(omnisharp_executable_path, stat.S_IEXEC) + + razor_omnisharp_ls_dir = os.path.join(os.path.dirname(__file__), "static", "RazorOmnisharp") + if not os.path.exists(razor_omnisharp_ls_dir): + os.makedirs(razor_omnisharp_ls_dir) + FileUtils.download_and_extract_archive( + logger, runtime_dependencies["RazorOmnisharp"]["url"], razor_omnisharp_ls_dir, "zip" + ) + razor_omnisharp_dll_path = os.path.join( + razor_omnisharp_ls_dir, runtime_dependencies["RazorOmnisharp"]["dll_path"] + ) + assert os.path.exists(razor_omnisharp_dll_path) + + return omnisharp_executable_path, razor_omnisharp_dll_path + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["OmniSharp"]: + """ + Starts the Omnisharp Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + """ + + async def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "textDocument/definition": + self.definition_available.set() + if registration["method"] == "textDocument/references": + self.references_available.set() + if registration["method"] == "textDocument/completion": + self.completions_available.set() + + async def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + # if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + # self.service_ready_event.set() + pass + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def check_experimental_status(params): + if params["quiescent"] == True: + self.server_ready.set() + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + async def workspace_configuration_handler(params): + # TODO: We do not know the appropriate way to handle this request. Should ideally contact the OmniSharp dev team + return [ + { + "RoslynExtensionsOptions": { + "EnableDecompilationSupport": False, + "EnableAnalyzersSupport": True, + "EnableImportCompletion": True, + "EnableAsyncCompletion": False, + "DocumentAnalysisTimeoutMs": 30000, + "DiagnosticWorkersThreadCount": 18, + "AnalyzeOpenDocumentsOnly": True, + "InlayHintsOptions": { + "EnableForParameters": False, + "ForLiteralParameters": False, + "ForIndexerParameters": False, + "ForObjectCreationParameters": False, + "ForOtherParameters": False, + "SuppressForParametersThatDifferOnlyBySuffix": False, + "SuppressForParametersThatMatchMethodIntent": False, + "SuppressForParametersThatMatchArgumentName": False, + "EnableForTypes": False, + "ForImplicitVariableTypes": False, + "ForLambdaParameterTypes": False, + "ForImplicitObjectCreation": False, + }, + "LocationPaths": None, + }, + "FormattingOptions": { + "OrganizeImports": False, + "EnableEditorConfigSupport": True, + "NewLine": "\n", + "UseTabs": False, + "TabSize": 4, + "IndentationSize": 4, + "SpacingAfterMethodDeclarationName": False, + "SeparateImportDirectiveGroups": False, + "SpaceWithinMethodDeclarationParenthesis": False, + "SpaceBetweenEmptyMethodDeclarationParentheses": False, + "SpaceAfterMethodCallName": False, + "SpaceWithinMethodCallParentheses": False, + "SpaceBetweenEmptyMethodCallParentheses": False, + "SpaceAfterControlFlowStatementKeyword": True, + "SpaceWithinExpressionParentheses": False, + "SpaceWithinCastParentheses": False, + "SpaceWithinOtherParentheses": False, + "SpaceAfterCast": False, + "SpaceBeforeOpenSquareBracket": False, + "SpaceBetweenEmptySquareBrackets": False, + "SpaceWithinSquareBrackets": False, + "SpaceAfterColonInBaseTypeDeclaration": True, + "SpaceAfterComma": True, + "SpaceAfterDot": False, + "SpaceAfterSemicolonsInForStatement": True, + "SpaceBeforeColonInBaseTypeDeclaration": True, + "SpaceBeforeComma": False, + "SpaceBeforeDot": False, + "SpaceBeforeSemicolonsInForStatement": False, + "SpacingAroundBinaryOperator": "single", + "IndentBraces": False, + "IndentBlock": True, + "IndentSwitchSection": True, + "IndentSwitchCaseSection": True, + "IndentSwitchCaseSectionWhenBlock": True, + "LabelPositioning": "oneLess", + "WrappingPreserveSingleLine": True, + "WrappingKeepStatementsOnSingleLine": True, + "NewLinesForBracesInTypes": True, + "NewLinesForBracesInMethods": True, + "NewLinesForBracesInProperties": True, + "NewLinesForBracesInAccessors": True, + "NewLinesForBracesInAnonymousMethods": True, + "NewLinesForBracesInControlBlocks": True, + "NewLinesForBracesInAnonymousTypes": True, + "NewLinesForBracesInObjectCollectionArrayInitializers": True, + "NewLinesForBracesInLambdaExpressionBody": True, + "NewLineForElse": True, + "NewLineForCatch": True, + "NewLineForFinally": True, + "NewLineForMembersInObjectInit": True, + "NewLineForMembersInAnonymousTypes": True, + "NewLineForClausesInQuery": True, + }, + "FileOptions": { + "SystemExcludeSearchPatterns": [ + "**/node_modules/**/*", + "**/bin/**/*", + "**/obj/**/*", + "**/.git/**/*", + "**/.git", + "**/.svn", + "**/.hg", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db", + ], + "ExcludeSearchPatterns": [], + }, + "RenameOptions": { + "RenameOverloads": False, + "RenameInStrings": False, + "RenameInComments": False, + }, + "ImplementTypeOptions": { + "InsertionBehavior": 0, + "PropertyGenerationBehavior": 0, + }, + "DotNetCliOptions": {"LocationPaths": None}, + "Plugins": {"LocationPaths": None}, + } + ] + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + + async with super().start_server(): + self.logger.log("Starting OmniSharp server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + self.server.notify.initialized({}) + with open(os.path.join(os.path.dirname(__file__), "workspace_did_change_configuration.json"), "r", encoding="utf-8") as f: + self.server.notify.workspace_did_change_configuration({ + "settings": json.load(f) + }) + assert "capabilities" in init_response + if ( + "definitionProvider" in init_response["capabilities"] + and init_response["capabilities"]["definitionProvider"] + ): + self.definition_available.set() + if ( + "referencesProvider" in init_response["capabilities"] + and init_response["capabilities"]["referencesProvider"] + ): + self.references_available.set() + + await self.definition_available.wait() + await self.references_available.wait() + + yield self diff --git a/src/solidlsp/language_servers/omnisharp/runtime_dependencies.json b/src/solidlsp/language_servers/omnisharp/runtime_dependencies.json new file mode 100644 index 00000000..7dfa1fd4 --- /dev/null +++ b/src/solidlsp/language_servers/omnisharp/runtime_dependencies.json @@ -0,0 +1,441 @@ +{ + "_description": "Used to download the runtime dependencies for running OmniSharp. Obtained from https://github.com/dotnet/vscode-csharp/blob/main/package.json", + "runtimeDependencies": [ + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 4 / x86)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x86-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "win32" + ], + "architectures": [ + "x86" + ], + "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", + "platformId": "win-x86", + "isFramework": true, + "integrity": "C81CE2099AD494EF63F9D88FAA70D55A68CF175810F944526FF94AAC7A5109F9", + "dotnet_version": "4", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 6 / x86)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x86-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "win32" + ], + "architectures": [ + "x86" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "win-x86", + "isFramework": false, + "integrity": "B7E62415CFC3DAC2154AC636C5BF0FB4B2C9BBF11B5A1FBF72381DDDED59791E", + "dotnet_version": "6", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 4 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x64-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "win32" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", + "platformId": "win-x64", + "isFramework": true, + "integrity": "BE0ED10AACEA17E14B78BD0D887DE5935D4ECA3712192A701F3F2100CA3C8B6E", + "dotnet_version": "4", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 6 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-x64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "win32" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "win-x64", + "isFramework": false, + "integrity": "A73327395E7EF92C1D8E307055463DA412662C03F077ECC743462FD2760BB537", + "dotnet_version": "6", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 4 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-arm64-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "win32" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10/OmniSharp.exe", + "platformId": "win-arm64", + "isFramework": true, + "integrity": "32FA0067B0639F87760CD1A769B16E6A53588C137C4D31661836CA4FB28D3DD6", + "dotnet_version": "4", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Windows (.NET 6 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-win-arm64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "win32" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "win-arm64", + "isFramework": false, + "integrity": "433F9B360CAA7B4DDD85C604D5C5542C1A718BCF2E71B2BCFC7526E6D41F4E8F", + "dotnet_version": "6", + "binaryName": "OmniSharp.exe" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for OSX (Mono / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "darwin" + ], + "architectures": [ + "x86_64", + "arm64" + ], + "binaries": [ + "./mono.osx", + "./run" + ], + "installTestPath": "./.omnisharp/1.39.10/run", + "platformId": "osx", + "isFramework": true, + "integrity": "2CC42F0EC7C30CFA8858501D12ECB6FB685A1FCFB8ECB35698A4B12406551968", + "dotnet_version": "mono" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for OSX (.NET 6 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-x64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "darwin" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "osx-x64", + "isFramework": false, + "integrity": "C9D6E9F2C839A66A7283AE6A9EC545EE049B48EB230D33E91A6322CB67FF9D97", + "dotnet_version": "6" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for OSX (.NET 6 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-osx-arm64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "darwin" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "osx-arm64", + "isFramework": false, + "integrity": "851350F52F83E3BAD5A92D113E4B9882FCD1DEB16AA84FF94B6F2CEE3C70051E", + "dotnet_version": "6" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (Mono / x86)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x86-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "linux" + ], + "architectures": [ + "x86", + "i686" + ], + "binaries": [ + "./mono.linux-x86", + "./run" + ], + "installTestPath": "./.omnisharp/1.39.10/run", + "platformId": "linux-x86", + "isFramework": true, + "integrity": "474B1CDBAE64CFEC655FB6B0659BCE481023C48274441C72991E67B6E13E56A1", + "dotnet_version": "mono" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (Mono / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x64-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "linux" + ], + "architectures": [ + "x86_64" + ], + "binaries": [ + "./mono.linux-x86_64", + "./run" + ], + "installTestPath": "./.omnisharp/1.39.10/run", + "platformId": "linux-x64", + "isFramework": true, + "integrity": "FB4CAA47343265100349375D79DBCCE1868950CED675CB07FCBE8462EDBCDD37", + "dotnet_version": "mono" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (.NET 6 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-x64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "linux" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "linux-x64", + "isFramework": false, + "integrity": "0926D3BEA060BF4373356B2FC0A68C10D0DE1B1150100B551BA5932814CE51E2", + "dotnet_version": "6", + "binaryName": "OmniSharp" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (Mono / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-arm64-1.39.10.zip", + "installPath": ".omnisharp/1.39.10", + "platforms": [ + "linux" + ], + "architectures": [ + "arm64" + ], + "binaries": [ + "./mono.linux-arm64", + "./run" + ], + "installTestPath": "./.omnisharp/1.39.10/run", + "platformId": "linux-arm64", + "isFramework": true, + "integrity": "478F3594DFD0167E9A56E36F0364A86C73F8132A3E7EA916CA1419EFE141D2CC", + "dotnet_version": "mono" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux (.NET 6 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-arm64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "linux" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "linux-arm64", + "isFramework": false, + "integrity": "6FB6A572043A74220A92F6C19C7BB0C3743321C7563A815FD2702EF4FA7D688E", + "dotnet_version": "6" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux musl (.NET 6 / x64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-musl-x64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "linux-musl" + ], + "architectures": [ + "x86_64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "linux-musl-x64", + "isFramework": false, + "integrity": "6BFDA3AD11DBB0C6514B86ECC3E1597CC41C6E309B7575F7C599E07D9E2AE610", + "dotnet_version": "6" + }, + { + "id": "OmniSharp", + "description": "OmniSharp for Linux musl (.NET 6 / arm64)", + "url": "https://roslynomnisharp.blob.core.windows.net/releases/1.39.10/omnisharp-linux-musl-arm64-net6.0-1.39.10.zip", + "installPath": ".omnisharp/1.39.10-net6.0", + "platforms": [ + "linux-musl" + ], + "architectures": [ + "arm64" + ], + "installTestPath": "./.omnisharp/1.39.10-net6.0/OmniSharp.dll", + "platformId": "linux-musl-arm64", + "isFramework": false, + "integrity": "DA63619EA024EB9BBF6DB5A85C6150CAB5C0BD554544A3596ED1B17F926D6875", + "dotnet_version": "6" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Windows / x64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/8d42e62ea4051381c219b3e31bc4eced/razorlanguageserver-win-x64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "win32" + ], + "architectures": [ + "x86_64" + ], + "platformId": "win-x64", + "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Windows / x86)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/e440c4f3a4a96334fe177513935fa010/razorlanguageserver-win-x86-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "win32" + ], + "architectures": [ + "x86" + ], + "platformId": "win-x86", + "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Windows / ARM64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/4ef26e45cf32fe8d51c0e7dd21f1fef6/razorlanguageserver-win-arm64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "win32" + ], + "architectures": [ + "arm64" + ], + "platformId": "win-arm64", + "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Linux / x64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/6d4e23a3c7cf0465743950a39515a716/razorlanguageserver-linux-x64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "linux" + ], + "architectures": [ + "x86_64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "linux-x64", + "dll_path": "OmniSharpPlugin/Microsoft.AspNetCore.Razor.OmniSharpPlugin.dll" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Linux ARM64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/85deebd44647ebf65724cc291d722283/razorlanguageserver-linux-arm64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "linux" + ], + "architectures": [ + "arm64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "linux-arm64" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Linux musl / x64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/4f0caa94ae182785655efb15eafcef23/razorlanguageserver-linux-musl-x64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "linux-musl" + ], + "architectures": [ + "x86_64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "linux-musl-x64" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (Linux musl ARM64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/0a24828206a6f3b4bc743d058ef88ce7/razorlanguageserver-linux-musl-arm64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "linux-musl" + ], + "architectures": [ + "arm64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "linux-musl-arm64" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (macOS / x64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/2afcafaf41082989efcc10405abb9314/razorlanguageserver-osx-x64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "darwin" + ], + "architectures": [ + "x86_64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "osx-x64" + }, + { + "id": "RazorOmnisharp", + "description": "Razor Language Server for OmniSharp (macOS ARM64)", + "url": "https://download.visualstudio.microsoft.com/download/pr/aee63398-023f-48db-bba2-30162c68f0c4/8bf2ed2f00d481a5987e3eb5165afddd/razorlanguageserver-osx-arm64-7.0.0-preview.23363.1.zip", + "installPath": ".razoromnisharp", + "platforms": [ + "darwin" + ], + "architectures": [ + "arm64" + ], + "binaries": [ + "./rzls" + ], + "platformId": "osx-arm64" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json b/src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json new file mode 100644 index 00000000..148841c3 --- /dev/null +++ b/src/solidlsp/language_servers/omnisharp/workspace_did_change_configuration.json @@ -0,0 +1,111 @@ +{ + "RoslynExtensionsOptions": { + "EnableDecompilationSupport": false, + "EnableAnalyzersSupport": true, + "EnableImportCompletion": true, + "EnableAsyncCompletion": false, + "DocumentAnalysisTimeoutMs": 30000, + "DiagnosticWorkersThreadCount": 18, + "AnalyzeOpenDocumentsOnly": true, + "InlayHintsOptions": { + "EnableForParameters": false, + "ForLiteralParameters": false, + "ForIndexerParameters": false, + "ForObjectCreationParameters": false, + "ForOtherParameters": false, + "SuppressForParametersThatDifferOnlyBySuffix": false, + "SuppressForParametersThatMatchMethodIntent": false, + "SuppressForParametersThatMatchArgumentName": false, + "EnableForTypes": false, + "ForImplicitVariableTypes": false, + "ForLambdaParameterTypes": false, + "ForImplicitObjectCreation": false + }, + "LocationPaths": null + }, + "FormattingOptions": { + "OrganizeImports": false, + "EnableEditorConfigSupport": true, + "NewLine": "\n", + "UseTabs": false, + "TabSize": 4, + "IndentationSize": 4, + "SpacingAfterMethodDeclarationName": false, + "SeparateImportDirectiveGroups": false, + "SpaceWithinMethodDeclarationParenthesis": false, + "SpaceBetweenEmptyMethodDeclarationParentheses": false, + "SpaceAfterMethodCallName": false, + "SpaceWithinMethodCallParentheses": false, + "SpaceBetweenEmptyMethodCallParentheses": false, + "SpaceAfterControlFlowStatementKeyword": true, + "SpaceWithinExpressionParentheses": false, + "SpaceWithinCastParentheses": false, + "SpaceWithinOtherParentheses": false, + "SpaceAfterCast": false, + "SpaceBeforeOpenSquareBracket": false, + "SpaceBetweenEmptySquareBrackets": false, + "SpaceWithinSquareBrackets": false, + "SpaceAfterColonInBaseTypeDeclaration": true, + "SpaceAfterComma": true, + "SpaceAfterDot": false, + "SpaceAfterSemicolonsInForStatement": true, + "SpaceBeforeColonInBaseTypeDeclaration": true, + "SpaceBeforeComma": false, + "SpaceBeforeDot": false, + "SpaceBeforeSemicolonsInForStatement": false, + "SpacingAroundBinaryOperator": "single", + "IndentBraces": false, + "IndentBlock": true, + "IndentSwitchSection": true, + "IndentSwitchCaseSection": true, + "IndentSwitchCaseSectionWhenBlock": true, + "LabelPositioning": "oneLess", + "WrappingPreserveSingleLine": true, + "WrappingKeepStatementsOnSingleLine": true, + "NewLinesForBracesInTypes": true, + "NewLinesForBracesInMethods": true, + "NewLinesForBracesInProperties": true, + "NewLinesForBracesInAccessors": true, + "NewLinesForBracesInAnonymousMethods": true, + "NewLinesForBracesInControlBlocks": true, + "NewLinesForBracesInAnonymousTypes": true, + "NewLinesForBracesInObjectCollectionArrayInitializers": true, + "NewLinesForBracesInLambdaExpressionBody": true, + "NewLineForElse": true, + "NewLineForCatch": true, + "NewLineForFinally": true, + "NewLineForMembersInObjectInit": true, + "NewLineForMembersInAnonymousTypes": true, + "NewLineForClausesInQuery": true + }, + "FileOptions": { + "SystemExcludeSearchPatterns": [ + "**/node_modules/**/*", + "**/bin/**/*", + "**/obj/**/*", + "**/.git/**/*", + "**/.git", + "**/.svn", + "**/.hg", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db" + ], + "ExcludeSearchPatterns": [] + }, + "RenameOptions": { + "RenameOverloads": false, + "RenameInStrings": false, + "RenameInComments": false + }, + "ImplementTypeOptions": { + "InsertionBehavior": 0, + "PropertyGenerationBehavior": 0 + }, + "DotNetCliOptions": { + "LocationPaths": null + }, + "Plugins": { + "LocationPaths": null + } +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/pyright_language_server/pyright_server.py b/src/solidlsp/language_servers/pyright_language_server/pyright_server.py new file mode 100644 index 00000000..48855831 --- /dev/null +++ b/src/solidlsp/language_servers/pyright_language_server/pyright_server.py @@ -0,0 +1,246 @@ +""" +Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. +""" + +import asyncio +import json +import logging +import os +import pathlib +import re +from contextlib import asynccontextmanager +from typing import AsyncIterator, Tuple + +from overrides import override + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig + + +class PyrightServer(LanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class using Pyright. + Contains various configurations and settings specific to Python. + """ + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a PyrightServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + logger, + repository_root_path, + # Note 1: we can also use `pyright-langserver --stdio` but it requires pyright to be installed with npm + # Note 2: we can also use `bpyright-langserver --stdio` if we ever are unhappy with pyright + ProcessLaunchInfo(cmd="python -m pyright.langserver --stdio", cwd=repository_root_path), + "python", + ) + + # Event to signal when initial workspace analysis is complete + self.analysis_complete = asyncio.Event() + self.found_source_files = False + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Pyright Language Server. + """ + # Create basic initialization parameters + initialize_params: InitializeParams = { # type: ignore + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": pathlib.Path(repository_absolute_path).as_uri(), + "initializationOptions": { + "exclude": [ + "**/__pycache__", + "**/.venv", + "**/.env", + "**/build", + "**/dist", + "**/.pixi", + ], + "reportMissingImports": "error", + }, + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + }, + "executeCommand": {"dynamicRegistration": True}, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] + }, + "hierarchicalDocumentSymbolSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + }, + "workspaceFolders": [ + {"uri": pathlib.Path(repository_absolute_path).as_uri(), "name": os.path.basename(repository_absolute_path)} + ], + } + + return initialize_params + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["PyrightServer"]: + """ + Starts the Pyright Language Server and waits for initial workspace analysis to complete. + + This prevents zombie processes by ensuring Pyright has finished its initial background + tasks before we consider the server ready. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and workspace analysis is complete + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown cleanly + ``` + """ + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def window_log_message(msg): + """ + Monitor Pyright's log messages to detect when initial analysis is complete. + Pyright logs "Found X source files" when it finishes scanning the workspace. + """ + message_text = msg.get("message", "") + self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) + + # Look for "Found X source files" which indicates workspace scanning is complete + # Unfortunately, pyright is unreliable and there seems to be no better way + if re.search(r"Found \d+ source files?", message_text): + self.logger.log("Pyright workspace scanning complete", logging.INFO) + self.found_source_files = True + self.analysis_complete.set() + self.completions_available.set() + + async def check_experimental_status(params): + """ + Also listen for experimental/serverStatus as a backup signal + """ + if params.get("quiescent") == True: + self.logger.log("Received experimental/serverStatus with quiescent=true", logging.INFO) + if not self.found_source_files: + self.analysis_complete.set() + self.completions_available.set() + + # Set up notification handlers + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + async with super().start_server(): + self.logger.log("Starting pyright-langserver server process", logging.INFO) + await self.server.start() + + # Send proper initialization parameters + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to pyright server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) + + # Verify that the server supports our required features + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + # Complete the initialization handshake + self.server.notify.initialized({}) + + # Wait for Pyright to complete its initial workspace analysis + # This prevents zombie processes by ensuring background tasks finish + self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) + try: + await asyncio.wait_for(self.analysis_complete.wait(), timeout=1.0) + self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) + except asyncio.TimeoutError: + self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) + # Fallback: assume analysis is complete after timeout + self.analysis_complete.set() + self.completions_available.set() + + yield self \ No newline at end of file diff --git a/src/solidlsp/language_servers/rust_analyzer/initialize_params.json b/src/solidlsp/language_servers/rust_analyzer/initialize_params.json new file mode 100644 index 00000000..00368eed --- /dev/null +++ b/src/solidlsp/language_servers/rust_analyzer/initialize_params.json @@ -0,0 +1,917 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "Visual Studio Code - Insiders", + "version": "1.82.0-insider" + }, + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "configuration": true, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": true, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": true, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": false + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "window": { + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": true + } + }, + "showDocument": { + "support": true + }, + "workDoneProgress": true + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0", + "allowedTags": [ + "ul", + "li", + "p", + "code", + "blockquote", + "ol", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "em", + "pre", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "div", + "del", + "a", + "strong", + "br", + "img", + "span" + ] + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + }, + "experimental": { + "snippetTextEdit": true, + "codeActionGroup": true, + "hoverActions": true, + "serverStatusNotification": true, + "colorDiagnosticOutput": true, + "openServerLogs": true, + "localDocs": true, + "commands": { + "commands": [ + "rust-analyzer.runSingle", + "rust-analyzer.debugSingle", + "rust-analyzer.showReferences", + "rust-analyzer.gotoLocation", + "editor.action.triggerParameterHints" + ] + } + } + }, + "initializationOptions": { + "cargoRunner": null, + "runnables": { + "extraEnv": null, + "problemMatcher": [ + "$rustc" + ], + "command": null, + "extraArgs": [] + }, + "statusBar": { + "clickAction": "openLogs" + }, + "server": { + "path": null, + "extraEnv": null + }, + "trace": { + "server": "verbose", + "extension": false + }, + "debug": { + "engine": "auto", + "sourceFileMap": { + "/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust" + }, + "openDebugPane": false, + "engineSettings": {} + }, + "restartServerOnConfigChange": false, + "typing": { + "continueCommentsOnNewline": true, + "autoClosingAngleBrackets": { + "enable": false + } + }, + "diagnostics": { + "previewRustcOutput": false, + "useRustcErrorCode": false, + "disabled": [], + "enable": true, + "experimental": { + "enable": false + }, + "remapPrefix": {}, + "warningsAsHint": [], + "warningsAsInfo": [] + }, + "discoverProjectRunner": null, + "showUnlinkedFileNotification": true, + "showDependenciesExplorer": true, + "assist": { + "emitMustUse": false, + "expressionFillDefault": "todo" + }, + "cachePriming": { + "enable": true, + "numThreads": 0 + }, + "cargo": { + "autoreload": true, + "buildScripts": { + "enable": true, + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "overrideCommand": null, + "useRustcWrapper": true + }, + "cfgs": {}, + "extraArgs": [], + "extraEnv": {}, + "features": [], + "noDefaultFeatures": false, + "sysroot": "discover", + "sysrootSrc": null, + "target": null, + "unsetTest": [ + "core" + ] + }, + "checkOnSave": true, + "check": { + "allTargets": true, + "command": "check", + "extraArgs": [], + "extraEnv": {}, + "features": null, + "ignore": [], + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "noDefaultFeatures": null, + "overrideCommand": null, + "targets": null + }, + "completion": { + "autoimport": { + "enable": true + }, + "autoself": { + "enable": true + }, + "callable": { + "snippets": "fill_arguments" + }, + "fullFunctionSignatures": { + "enable": false + }, + "limit": null, + "postfix": { + "enable": true + }, + "privateEditable": { + "enable": false + }, + "snippets": { + "custom": { + "Arc::new": { + "postfix": "arc", + "body": "Arc::new(${receiver})", + "requires": "std::sync::Arc", + "description": "Put the expression into an `Arc`", + "scope": "expr" + }, + "Rc::new": { + "postfix": "rc", + "body": "Rc::new(${receiver})", + "requires": "std::rc::Rc", + "description": "Put the expression into an `Rc`", + "scope": "expr" + }, + "Box::pin": { + "postfix": "pinbox", + "body": "Box::pin(${receiver})", + "requires": "std::boxed::Box", + "description": "Put the expression into a pinned `Box`", + "scope": "expr" + }, + "Ok": { + "postfix": "ok", + "body": "Ok(${receiver})", + "description": "Wrap the expression in a `Result::Ok`", + "scope": "expr" + }, + "Err": { + "postfix": "err", + "body": "Err(${receiver})", + "description": "Wrap the expression in a `Result::Err`", + "scope": "expr" + }, + "Some": { + "postfix": "some", + "body": "Some(${receiver})", + "description": "Wrap the expression in an `Option::Some`", + "scope": "expr" + } + } + } + }, + "files": { + "excludeDirs": [], + "watcher": "client" + }, + "highlightRelated": { + "breakPoints": { + "enable": true + }, + "closureCaptures": { + "enable": true + }, + "exitPoints": { + "enable": true + }, + "references": { + "enable": true + }, + "yieldPoints": { + "enable": true + } + }, + "hover": { + "actions": { + "debug": { + "enable": true + }, + "enable": true, + "gotoTypeDef": { + "enable": true + }, + "implementations": { + "enable": true + }, + "references": { + "enable": false + }, + "run": { + "enable": true + } + }, + "documentation": { + "enable": true, + "keywords": { + "enable": true + } + }, + "links": { + "enable": true + }, + "memoryLayout": { + "alignment": "hexadecimal", + "enable": true, + "niches": false, + "offset": "hexadecimal", + "size": "both" + } + }, + "imports": { + "granularity": { + "enforce": false, + "group": "crate" + }, + "group": { + "enable": true + }, + "merge": { + "glob": true + }, + "preferNoStd": false, + "preferPrelude": false, + "prefix": "plain" + }, + "inlayHints": { + "bindingModeHints": { + "enable": false + }, + "chainingHints": { + "enable": true + }, + "closingBraceHints": { + "enable": true, + "minLines": 25 + }, + "closureCaptureHints": { + "enable": false + }, + "closureReturnTypeHints": { + "enable": "never" + }, + "closureStyle": "impl_fn", + "discriminantHints": { + "enable": "never" + }, + "expressionAdjustmentHints": { + "enable": "never", + "hideOutsideUnsafe": false, + "mode": "prefix" + }, + "lifetimeElisionHints": { + "enable": "never", + "useParameterNames": false + }, + "maxLength": 25, + "parameterHints": { + "enable": true + }, + "reborrowHints": { + "enable": "never" + }, + "renderColons": true, + "typeHints": { + "enable": true, + "hideClosureInitialization": false, + "hideNamedConstructor": false + } + }, + "interpret": { + "tests": false + }, + "joinLines": { + "joinAssignments": true, + "joinElseIf": true, + "removeTrailingComma": true, + "unwrapTrivialBlock": true + }, + "lens": { + "debug": { + "enable": true + }, + "enable": true, + "forceCustomCommands": true, + "implementations": { + "enable": true + }, + "location": "above_name", + "references": { + "adt": { + "enable": false + }, + "enumVariant": { + "enable": false + }, + "method": { + "enable": false + }, + "trait": { + "enable": false + } + }, + "run": { + "enable": true + } + }, + "linkedProjects": [], + "lru": { + "capacity": null, + "query": { + "capacities": {} + } + }, + "notifications": { + "cargoTomlNotFound": true + }, + "numThreads": null, + "procMacro": { + "attributes": { + "enable": true + }, + "enable": true, + "ignored": {}, + "server": null + }, + "references": { + "excludeImports": false + }, + "rust": { + "analyzerTargetDir": null + }, + "rustc": { + "source": null + }, + "rustfmt": { + "extraArgs": [], + "overrideCommand": null, + "rangeFormatting": { + "enable": false + } + }, + "semanticHighlighting": { + "doc": { + "comment": { + "inject": { + "enable": true + } + } + }, + "nonStandardTokens": true, + "operator": { + "enable": true, + "specialization": { + "enable": false + } + }, + "punctuation": { + "enable": false, + "separate": { + "macro": { + "bang": false + } + }, + "specialization": { + "enable": false + } + }, + "strings": { + "enable": true + } + }, + "signatureInfo": { + "detail": "full", + "documentation": { + "enable": true + } + }, + "workspace": { + "symbol": { + "search": { + "kind": "only_types", + "limit": 128, + "scope": "workspace" + } + } + } + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/rust_analyzer/runtime_dependencies.json b/src/solidlsp/language_servers/rust_analyzer/runtime_dependencies.json new file mode 100644 index 00000000..517d8b87 --- /dev/null +++ b/src/solidlsp/language_servers/rust_analyzer/runtime_dependencies.json @@ -0,0 +1,29 @@ +{ + "_description": "Used to download the runtime dependencies for running RustAnalyzer. Obtained from https://github.com/rust-lang/rust-analyzer/releases", + "runtimeDependencies": [ + { + "id": "RustAnalyzer", + "description": "RustAnalyzer for Linux (x64)", + "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-aarch64-apple-darwin.gz", + "platformId": "osx-arm64", + "archiveType": "gz", + "binaryName": "rust_analyzer" + }, + { + "id": "RustAnalyzer", + "description": "RustAnalyzer for Linux (x64)", + "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-x86_64-unknown-linux-gnu.gz", + "platformId": "linux-x64", + "archiveType": "gz", + "binaryName": "rust_analyzer" + }, + { + "id": "RustAnalyzer", + "description": "RustAnalyzer for Windows (x64)", + "url": "https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-x86_64-pc-windows-msvc.zip", + "platformId": "win-x64", + "archiveType": "zip", + "binaryName": "rust-analyzer.exe" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py new file mode 100644 index 00000000..65f3598f --- /dev/null +++ b/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py @@ -0,0 +1,185 @@ +""" +Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. +""" + +import asyncio +import json +import logging +import os +import stat +import pathlib +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from overrides import override + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils + + +class RustAnalyzer(LanguageServer): + """ + Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + rustanalyzer_executable_path = self.setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), + "rust", + ) + self.server_ready = asyncio.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["target"] + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for rust_analyzer. + """ + platform_id = PlatformUtils.get_platform_id() + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r", encoding="utf-8") as f: + d = json.load(f) + del d["_description"] + + # assert platform_id.value in [ + # "linux-x64", + # "win-x64", + # ], "Only linux-x64 and win-x64 platform is supported for in multilspy at the moment" + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [ + dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value + ] + assert len(runtime_dependencies) == 1 + dependency = runtime_dependencies[0] + + rustanalyzer_ls_dir = os.path.join(os.path.dirname(__file__), "static", "RustAnalyzer") + rustanalyzer_executable_path = os.path.join(rustanalyzer_ls_dir, dependency["binaryName"]) + if not os.path.exists(rustanalyzer_ls_dir): + os.makedirs(rustanalyzer_ls_dir) + if dependency["archiveType"] == "gz": + FileUtils.download_and_extract_archive( + logger, dependency["url"], rustanalyzer_executable_path, dependency["archiveType"] + ) + else: + FileUtils.download_and_extract_archive( + logger, dependency["url"], rustanalyzer_ls_dir, dependency["archiveType"] + ) + assert os.path.exists(rustanalyzer_executable_path) + os.chmod(rustanalyzer_executable_path, stat.S_IEXEC) + + return rustanalyzer_executable_path + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Rust Analyzer Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r", encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["RustAnalyzer"]: + """ + Starts the Rust Analyzer Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + """ + + async def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + async def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def check_experimental_status(params): + if params["quiescent"] == True: + self.server_ready.set() + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + async with super().start_server(): + self.logger.log("Starting RustAnalyzer server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "resolveProvider": True, + "triggerCharacters": [":", ".", "'", "("], + "completionItem": {"labelDetailsSupport": True}, + } + self.server.notify.initialized({}) + self.completions_available.set() + + await self.server_ready.wait() + + yield self diff --git a/src/solidlsp/language_servers/solargraph/initialize_params.json b/src/solidlsp/language_servers/solargraph/initialize_params.json new file mode 100644 index 00000000..2cbb41f8 --- /dev/null +++ b/src/solidlsp/language_servers/solargraph/initialize_params.json @@ -0,0 +1,15 @@ +{ + "_description": "This file contains the initialization parameters for the Solargraph Language Server.", + "processId": "$processId", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} diff --git a/src/solidlsp/language_servers/solargraph/runtime_dependencies.json b/src/solidlsp/language_servers/solargraph/runtime_dependencies.json new file mode 100644 index 00000000..38cc2372 --- /dev/null +++ b/src/solidlsp/language_servers/solargraph/runtime_dependencies.json @@ -0,0 +1,11 @@ +{ + "_description": "This file contains URLs and other metadata required for downloading and installing the Solargraph language server.", + "runtimeDependencies": [ + { + "url": "https://rubygems.org/downloads/solargraph-0.51.1.gem", + "installCommand": "gem install solargraph -v 0.51.1", + "binaryName": "solargraph", + "archiveType": "gem" + } + ] +} diff --git a/src/solidlsp/language_servers/solargraph/solargraph.py b/src/solidlsp/language_servers/solargraph/solargraph.py new file mode 100644 index 00000000..099ff9d6 --- /dev/null +++ b/src/solidlsp/language_servers/solargraph/solargraph.py @@ -0,0 +1,189 @@ +""" +Provides Ruby specific instantiation of the LanguageServer class using Solargraph. +Contains various configurations and settings specific to Ruby. +""" + +import asyncio +import json +import logging +import os +import stat +import subprocess +import pathlib +from contextlib import asynccontextmanager +from typing import AsyncIterator, override + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils, PlatformId + + +class Solargraph(LanguageServer): + """ + Provides Ruby specific instantiation of the LanguageServer class using Solargraph. + Contains various configurations and settings specific to Ruby. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a Solargraph instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + solargraph_executable_path = self.setup_runtime_dependencies(logger, config, repository_root_path) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path), + "ruby", + ) + self.server_ready = asyncio.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["vendor"] + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig, repository_root_path: str) -> str: + """ + Setup runtime dependencies for Solargraph. + """ + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r", encoding="utf-8") as f: + d = json.load(f) + del d["_description"] + + dependency = d["runtimeDependencies"][0] + + # Check if Ruby is installed + try: + result = subprocess.run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path) + ruby_version = result.stdout.strip() + logger.log(f"Ruby version: {ruby_version}", logging.INFO) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Error checking for Ruby installation: {e.stderr}") + except FileNotFoundError: + raise RuntimeError("Ruby is not installed. Please install Ruby before continuing.") + + # Check if solargraph is installed + try: + result = subprocess.run(["gem", "list", "^solargraph$", "-i"], check=False, capture_output=True, text=True, cwd=repository_root_path) + if result.stdout.strip() == "false": + logger.log("Installing Solargraph...", logging.INFO) + subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path) + + # Get the gem executable path directly + result = subprocess.run(["gem", "which", "solargraph"], check=True, capture_output=True, text=True, cwd=repository_root_path) + gem_path = result.stdout.strip() + bin_dir = os.path.join(os.path.dirname(os.path.dirname(gem_path)), "bin") + executable_path = os.path.join(bin_dir, "solargraph") + + if not os.path.exists(executable_path): + raise RuntimeError(f"Solargraph executable not found at {executable_path}") + + # Ensure the executable has the right permissions + os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IEXEC) + + return executable_path + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to check or install Solargraph. {e.stderr}") + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Solargraph Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r", encoding="utf-8") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["Solargraph"]: + """ + Starts the Solargraph Language Server for Ruby, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + """ + + async def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + async def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + async with super().start_server(): + self.logger.log("Starting solargraph server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + self.logger.log(f"Sending init params: {json.dumps(initialize_params, indent=4)}", logging.INFO) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log(f"Received init response: {init_response}", logging.INFO) + assert init_response["capabilities"]["textDocumentSync"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "resolveProvider": True, + "triggerCharacters": [".", ":", "@"], + } + self.server.notify.initialized({}) + self.completions_available.set() + + self.server_ready.set() + await self.server_ready.wait() + + yield self diff --git a/src/solidlsp/language_servers/typescript_language_server/initialize_params.json b/src/solidlsp/language_servers/typescript_language_server/initialize_params.json new file mode 100644 index 00000000..6209ba47 --- /dev/null +++ b/src/solidlsp/language_servers/typescript_language_server/initialize_params.json @@ -0,0 +1,906 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "Visual Studio Code - Insiders", + "version": "1.82.0-insider" + }, + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "configuration": true, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": true, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": true, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": false + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0", + "allowedTags": [ + "ul", + "li", + "p", + "code", + "blockquote", + "ol", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "em", + "pre", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "div", + "del", + "a", + "strong", + "br", + "img", + "span" + ] + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + }, + "experimental": { + "snippetTextEdit": true, + "codeActionGroup": true, + "hoverActions": true, + "serverStatusNotification": true, + "colorDiagnosticOutput": true, + "openServerLogs": true, + "localDocs": true, + "commands": { + "commands": [ + "rust-analyzer.runSingle", + "rust-analyzer.debugSingle", + "rust-analyzer.showReferences", + "rust-analyzer.gotoLocation", + "editor.action.triggerParameterHints" + ] + } + } + }, + "initializationOptions": { + "cargoRunner": null, + "runnables": { + "extraEnv": null, + "problemMatcher": [ + "$rustc" + ], + "command": null, + "extraArgs": [] + }, + "statusBar": { + "clickAction": "openLogs" + }, + "server": { + "path": null, + "extraEnv": null + }, + "trace": { + "server": "verbose", + "extension": false + }, + "debug": { + "engine": "auto", + "sourceFileMap": { + "/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust" + }, + "openDebugPane": false, + "engineSettings": {} + }, + "restartServerOnConfigChange": false, + "typing": { + "continueCommentsOnNewline": true, + "autoClosingAngleBrackets": { + "enable": false + } + }, + "diagnostics": { + "previewRustcOutput": false, + "useRustcErrorCode": false, + "disabled": [], + "enable": true, + "experimental": { + "enable": false + }, + "remapPrefix": {}, + "warningsAsHint": [], + "warningsAsInfo": [] + }, + "discoverProjectRunner": null, + "showUnlinkedFileNotification": true, + "showDependenciesExplorer": true, + "assist": { + "emitMustUse": false, + "expressionFillDefault": "todo" + }, + "cachePriming": { + "enable": true, + "numThreads": 0 + }, + "cargo": { + "autoreload": true, + "buildScripts": { + "enable": true, + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "overrideCommand": null, + "useRustcWrapper": true + }, + "cfgs": {}, + "extraArgs": [], + "extraEnv": {}, + "features": [], + "noDefaultFeatures": false, + "sysroot": "discover", + "sysrootSrc": null, + "target": null, + "unsetTest": [ + "core" + ] + }, + "checkOnSave": true, + "check": { + "allTargets": true, + "command": "check", + "extraArgs": [], + "extraEnv": {}, + "features": null, + "ignore": [], + "invocationLocation": "workspace", + "invocationStrategy": "per_workspace", + "noDefaultFeatures": null, + "overrideCommand": null, + "targets": null + }, + "completion": { + "autoimport": { + "enable": true + }, + "autoself": { + "enable": true + }, + "callable": { + "snippets": "fill_arguments" + }, + "fullFunctionSignatures": { + "enable": false + }, + "limit": null, + "postfix": { + "enable": true + }, + "privateEditable": { + "enable": false + }, + "snippets": { + "custom": { + "Arc::new": { + "postfix": "arc", + "body": "Arc::new(${receiver})", + "requires": "std::sync::Arc", + "description": "Put the expression into an `Arc`", + "scope": "expr" + }, + "Rc::new": { + "postfix": "rc", + "body": "Rc::new(${receiver})", + "requires": "std::rc::Rc", + "description": "Put the expression into an `Rc`", + "scope": "expr" + }, + "Box::pin": { + "postfix": "pinbox", + "body": "Box::pin(${receiver})", + "requires": "std::boxed::Box", + "description": "Put the expression into a pinned `Box`", + "scope": "expr" + }, + "Ok": { + "postfix": "ok", + "body": "Ok(${receiver})", + "description": "Wrap the expression in a `Result::Ok`", + "scope": "expr" + }, + "Err": { + "postfix": "err", + "body": "Err(${receiver})", + "description": "Wrap the expression in a `Result::Err`", + "scope": "expr" + }, + "Some": { + "postfix": "some", + "body": "Some(${receiver})", + "description": "Wrap the expression in an `Option::Some`", + "scope": "expr" + } + } + } + }, + "files": { + "excludeDirs": [], + "watcher": "client" + }, + "highlightRelated": { + "breakPoints": { + "enable": true + }, + "closureCaptures": { + "enable": true + }, + "exitPoints": { + "enable": true + }, + "references": { + "enable": true + }, + "yieldPoints": { + "enable": true + } + }, + "hover": { + "actions": { + "debug": { + "enable": true + }, + "enable": true, + "gotoTypeDef": { + "enable": true + }, + "implementations": { + "enable": true + }, + "references": { + "enable": false + }, + "run": { + "enable": true + } + }, + "documentation": { + "enable": true, + "keywords": { + "enable": true + } + }, + "links": { + "enable": true + }, + "memoryLayout": { + "alignment": "hexadecimal", + "enable": true, + "niches": false, + "offset": "hexadecimal", + "size": "both" + } + }, + "imports": { + "granularity": { + "enforce": false, + "group": "crate" + }, + "group": { + "enable": true + }, + "merge": { + "glob": true + }, + "preferNoStd": false, + "preferPrelude": false, + "prefix": "plain" + }, + "inlayHints": { + "bindingModeHints": { + "enable": false + }, + "chainingHints": { + "enable": true + }, + "closingBraceHints": { + "enable": true, + "minLines": 25 + }, + "closureCaptureHints": { + "enable": false + }, + "closureReturnTypeHints": { + "enable": "never" + }, + "closureStyle": "impl_fn", + "discriminantHints": { + "enable": "never" + }, + "expressionAdjustmentHints": { + "enable": "never", + "hideOutsideUnsafe": false, + "mode": "prefix" + }, + "lifetimeElisionHints": { + "enable": "never", + "useParameterNames": false + }, + "maxLength": 25, + "parameterHints": { + "enable": true + }, + "reborrowHints": { + "enable": "never" + }, + "renderColons": true, + "typeHints": { + "enable": true, + "hideClosureInitialization": false, + "hideNamedConstructor": false + } + }, + "interpret": { + "tests": false + }, + "joinLines": { + "joinAssignments": true, + "joinElseIf": true, + "removeTrailingComma": true, + "unwrapTrivialBlock": true + }, + "lens": { + "debug": { + "enable": true + }, + "enable": true, + "forceCustomCommands": true, + "implementations": { + "enable": true + }, + "location": "above_name", + "references": { + "adt": { + "enable": false + }, + "enumVariant": { + "enable": false + }, + "method": { + "enable": false + }, + "trait": { + "enable": false + } + }, + "run": { + "enable": true + } + }, + "linkedProjects": [], + "lru": { + "capacity": null, + "query": { + "capacities": {} + } + }, + "notifications": { + "cargoTomlNotFound": true + }, + "numThreads": null, + "procMacro": { + "attributes": { + "enable": true + }, + "enable": true, + "ignored": {}, + "server": null + }, + "references": { + "excludeImports": false + }, + "rust": { + "analyzerTargetDir": null + }, + "rustc": { + "source": null + }, + "rustfmt": { + "extraArgs": [], + "overrideCommand": null, + "rangeFormatting": { + "enable": false + } + }, + "semanticHighlighting": { + "doc": { + "comment": { + "inject": { + "enable": true + } + } + }, + "nonStandardTokens": true, + "operator": { + "enable": true, + "specialization": { + "enable": false + } + }, + "punctuation": { + "enable": false, + "separate": { + "macro": { + "bang": false + } + }, + "specialization": { + "enable": false + } + }, + "strings": { + "enable": true + } + }, + "signatureInfo": { + "detail": "full", + "documentation": { + "enable": true + } + }, + "workspace": { + "symbol": { + "search": { + "kind": "only_types", + "limit": 128, + "scope": "workspace" + } + } + } + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/typescript_language_server/runtime_dependencies.json b/src/solidlsp/language_servers/typescript_language_server/runtime_dependencies.json new file mode 100644 index 00000000..90824a7c --- /dev/null +++ b/src/solidlsp/language_servers/typescript_language_server/runtime_dependencies.json @@ -0,0 +1,16 @@ +{ + "_description": "Used to download the runtime dependencies for running typescript-language-server. Obtained from https://github.com/typescript-language-server/typescript-language-server/releases", + "runtimeDependencies": [ + { + "id": "typescript", + "description": "typescript package for Linux, OSX, and Windows. Both x64 and arm64 are supported.", + "command": "npm install --prefix ./ typescript@5.5.4" + }, + { + "id": "typescript-language-server", + "description": "typescript-language-server package for Linux, OSX, and Windows. Both x64 and arm64 are supported.", + "command": "npm install --prefix ./ typescript-language-server@4.3.3" + } + ] +} + diff --git a/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py new file mode 100644 index 00000000..479936c9 --- /dev/null +++ b/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py @@ -0,0 +1,245 @@ +""" +Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. +""" + +import asyncio +import json +import logging +import os +import pathlib +import shutil +import subprocess +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from time import sleep + +from overrides import override + +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.multilspy_utils import PlatformId, PlatformUtils + +# Platform-specific imports +if os.name != 'nt': # Unix-like systems + import pwd +else: + # Dummy pwd module for Windows + class pwd: + @staticmethod + def getpwuid(uid): + return type('obj', (), {'pw_name': os.environ.get('USERNAME', 'unknown')})() + + +# Conditionally import pwd module (Unix-only) +if not PlatformUtils.get_platform_id().value.startswith("win"): + import pwd + + +class TypeScriptLanguageServer(LanguageServer): + """ + Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a TypeScriptLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + ts_lsp_executable_path = self.setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=ts_lsp_executable_path, cwd=repository_root_path), + "typescript", + ) + self.server_ready = asyncio.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "dist", + "build", + "coverage", + ] + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for TypeScript Language Server. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + PlatformId.WIN_arm64, + ] + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment" + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json")) as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d.get("runtimeDependencies", []) + tsserver_ls_dir = os.path.join(os.path.dirname(__file__), "static", "ts-lsp") + tsserver_executable_path = os.path.join(tsserver_ls_dir, "typescript-language-server") + + # Verify both node and npm are installed + is_node_installed = shutil.which('node') is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which('npm') is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + # Install typescript and typescript-language-server if not already installed + if not os.path.exists(tsserver_ls_dir): + os.makedirs(tsserver_ls_dir, exist_ok=True) + for dependency in runtime_dependencies: + # Windows doesn't support the 'user' parameter and doesn't have pwd module + if PlatformUtils.get_platform_id().value.startswith("win"): + subprocess.run( + dependency["command"], + shell=True, + check=True, + cwd=tsserver_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + else: + # On Unix-like systems, run as non-root user + user = pwd.getpwuid(os.getuid()).pw_name + subprocess.run( + dependency["command"], + shell=True, + check=True, + user=user, + cwd=tsserver_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") + + assert os.path.exists(tsserver_executable_path), "typescript-language-server executable not found. Please install typescript-language-server and try again." + return f"{tsserver_executable_path} --stdio" + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the TypeScript Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json")) as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["TypeScriptLanguageServer"]: + """ + Starts the TypeScript Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + """ + + async def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + # TypeScript doesn't have a direct equivalent to resolve_main_method + # You might want to set a different flag or remove this line + # self.resolve_main_method_available.set() + return + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + + async def check_experimental_status(params): + """ + Also listen for experimental/serverStatus as a backup signal + """ + if params.get("quiescent") == True: + self.server_ready.set() + self.completions_available.set() + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + async with super().start_server(): + self.logger.log("Starting TypeScript server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + + # TypeScript-specific capability checks + assert init_response["capabilities"]["textDocumentSync"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": ['.', '"', "'", '/', '@', '<'], + "resolveProvider": True + } + + self.server.notify.initialized({}) + try: + await asyncio.wait_for(self.server_ready.wait(), timeout=1.0) + except asyncio.TimeoutError: + self.logger.log("Timeout waiting for TypeScript server to become ready, proceeding anyway", logging.INFO) + # Fallback: assume server is ready after timeout + self.server_ready.set() + self.completions_available.set() + + yield self + + @override + # For some reason, the LS may need longer to process this, so we just retry + async def _send_references_request(self, relative_file_path: str, line: int, column: int): + # TODO: The LS doesn't return references contained in other files if it doesn't sleep. This is + # despite the LS having processed requests already. I don't know what causes this, but sleeping + # one second helps. It may be that sleeping only once is enough but that's hard to reliably test. + # It may be that even this 1sec is not enough in larger TS projects, at some point we should find what + # causes this and solve it. + sleep(1) + return await super()._send_references_request(relative_file_path, line, column) From 2a60b71047fff40dd15a0eece52f6bd2cdb04b19 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 13:38:40 +0200 Subject: [PATCH 04/30] Complete interface for server stop/start --- src/solidlsp/ls.py | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 4d37ca7b..bd1eca2d 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -6,6 +6,7 @@ import pathlib import pickle import re import threading +from abc import abstractmethod, ABC from collections import defaultdict from contextlib import contextmanager from copy import copy @@ -35,7 +36,7 @@ from serena.text_utils import MatchedConsecutiveLines, search_files from solidlsp.ls_handler import SolidLanguageServerHandler -class SolidLanguageServer: +class SolidLanguageServer(ABC): """ The LanguageServer class provides a language agnostic interface to the Language Server Protocol. It is used to communicate with Language Servers of different programming languages. @@ -63,7 +64,7 @@ class SolidLanguageServer: :return LanguageServer: A language specific LanguageServer instance. """ if config.code_language == Language.PYTHON: - from multilspy.language_servers.pyright_language_server.pyright_server import ( + from solidlsp.language_servers.pyright_language_server.pyright_server import ( PyrightServer, ) @@ -76,50 +77,50 @@ class SolidLanguageServer: # return JediServer(config, logger, repository_root_path) elif config.code_language == Language.JAVA: - from multilspy.language_servers.eclipse_jdtls.eclipse_jdtls import ( + from solidlsp.language_servers.eclipse_jdtls.eclipse_jdtls import ( EclipseJDTLS, ) return EclipseJDTLS(config, logger, repository_root_path) elif config.code_language == Language.KOTLIN: - from multilspy.language_servers.kotlin_language_server.kotlin_language_server import ( + from solidlsp.language_servers.kotlin_language_server.kotlin_language_server import ( KotlinLanguageServer, ) return KotlinLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.RUST: - from multilspy.language_servers.rust_analyzer.rust_analyzer import ( + from solidlsp.language_servers.rust_analyzer.rust_analyzer import ( RustAnalyzer, ) return RustAnalyzer(config, logger, repository_root_path) elif config.code_language == Language.CSHARP: - from multilspy.language_servers.omnisharp.omnisharp import OmniSharp + from solidlsp.language_servers.omnisharp.omnisharp import OmniSharp return OmniSharp(config, logger, repository_root_path) elif config.code_language in [Language.TYPESCRIPT, Language.JAVASCRIPT]: - from multilspy.language_servers.typescript_language_server.typescript_language_server import ( + from solidlsp.language_servers.typescript_language_server.typescript_language_server import ( TypeScriptLanguageServer, ) return TypeScriptLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.GO: - from multilspy.language_servers.gopls.gopls import Gopls + from solidlsp.language_servers.gopls.gopls import Gopls return Gopls(config, logger, repository_root_path) elif config.code_language == Language.RUBY: - from multilspy.language_servers.solargraph.solargraph import Solargraph + from solidlsp.language_servers.solargraph.solargraph import Solargraph return Solargraph(config, logger, repository_root_path) elif config.code_language == Language.DART: - from multilspy.language_servers.dart_language_server.dart_language_server import DartLanguageServer + from solidlsp.language_servers.dart_language_server.dart_language_server import DartLanguageServer return DartLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.CPP: - from multilspy.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer + from solidlsp.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer return ClangdLanguageServer(config, logger, repository_root_path) elif config.code_language == Language.PHP: - from multilspy.language_servers.intelephense.intelephense import Intelephense + from solidlsp.language_servers.intelephense.intelephense import Intelephense return Intelephense(config, logger, repository_root_path) else: @@ -328,10 +329,15 @@ class SolidLanguageServer: def start_server(self) -> Iterator["SolidLanguageServer"]: self.start() yield self - self._shutdown() + self.stop() - def _start_server(self) -> None: + def _start_server_process(self) -> None: self.server_started = True + self._start_server() + + @abstractmethod + def _start_server(self): + pass @contextmanager def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]: @@ -1615,9 +1621,12 @@ class SolidLanguageServer: :return: self for method chaining """ self.logger.log(f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}", logging.INFO) - self._server_context = self._start_server() + self._server_context = self._start_server_process() return self + def stop(self, shutdown_timeout: float = 2.0) -> None: + self._shutdown(timeout=shutdown_timeout) + @property def language_server(self) -> Self: return self @@ -1814,7 +1823,6 @@ class SolidPyrightServer(SolidLanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - super()._start_server() self.logger.log("Starting pyright-langserver server process", logging.INFO) self.server.start() From 0a800d0606f813a76272effdff48d90cf74651dc Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 14:29:15 +0200 Subject: [PATCH 05/30] Adapt Pyright and Intelephense servers to solidlsp --- .../intelephense/intelephense.py | 80 +++---- .../pyright_language_server/pyright_server.py | 88 ++++--- src/solidlsp/ls.py | 223 +----------------- 3 files changed, 77 insertions(+), 314 deletions(-) diff --git a/src/solidlsp/language_servers/intelephense/intelephense.py b/src/solidlsp/language_servers/intelephense/intelephense.py index d2ad9117..9b6eb955 100644 --- a/src/solidlsp/language_servers/intelephense/intelephense.py +++ b/src/solidlsp/language_servers/intelephense/intelephense.py @@ -2,27 +2,25 @@ Provides PHP specific instantiation of the LanguageServer class using Intelephense. """ -import asyncio import json -import shutil import logging import os -import subprocess import pathlib -from contextlib import asynccontextmanager +import shutil +import subprocess from time import sleep -from typing import AsyncIterator from overrides import override -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import DefinitionParams, InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_utils import PlatformUtils, PlatformId +from solidlsp.ls import SolidLanguageServer -class Intelephense(LanguageServer): + +class Intelephense(SolidLanguageServer): """ Provides PHP specific instantiation of the LanguageServer class using Intelephense. """ @@ -109,7 +107,6 @@ class Intelephense(LanguageServer): ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), "php" ) - self.server_ready = asyncio.Event() self.request_id = 0 def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: @@ -136,16 +133,15 @@ class Intelephense(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["Intelephense"]: + def _start_server(self): """Start Intelephense server process""" - async def register_capability_handler(params): + def register_capability_handler(params): return - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - async def do_nothing(params): + def do_nothing(params): return self.server.on_request("client/registerCapability", register_capability_handler) @@ -153,48 +149,44 @@ class Intelephense(LanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - async with super().start_server(): - self.logger.log("Starting Intelephense server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting Intelephense server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - self.logger.log( - "After sent initialize params", - logging.INFO, - ) - - # Verify server capabilities - assert "textDocumentSync" in init_response["capabilities"] - assert "completionProvider" in init_response["capabilities"] - assert "definitionProvider" in init_response["capabilities"] + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + self.logger.log( + "After sent initialize params", + logging.INFO, + ) - self.server.notify.initialized({}) - self.completions_available.set() + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] - # Intelephense server is typically ready immediately after initialization - self.server_ready.set() - await self.server_ready.wait() + self.server.notify.initialized({}) + self.completions_available.set() + + # Intelephense server is typically ready immediately after initialization + # TODO: This is probably incorrect; the server does send an initialized notification, which we could wait for! - yield self - @override # For some reason, the LS may need longer to process this, so we just retry - async def _send_references_request(self, relative_file_path: str, line: int, column: int): + def _send_references_request(self, relative_file_path: str, line: int, column: int): # TODO: The LS doesn't return references contained in other files if it doesn't sleep. This is # despite the LS having processed requests already. I don't know what causes this, but sleeping # one second helps. It may be that sleeping only once is enough but that's hard to reliably test. # May be related to the time it takes to read the files or something like that. # The sleeping doesn't seem to be needed on all systems sleep(1) - return await super()._send_references_request(relative_file_path, line, column) + return super()._send_references_request(relative_file_path, line, column) @override - async def _send_definition_request(self, definition_params: DefinitionParams): + def _send_definition_request(self, definition_params: DefinitionParams): # TODO: same as above, also only a problem if the definition is in another file sleep(1) - return await super()._send_definition_request(definition_params) + return super()._send_definition_request(definition_params) diff --git a/src/solidlsp/language_servers/pyright_language_server/pyright_server.py b/src/solidlsp/language_servers/pyright_language_server/pyright_server.py index 48855831..b3490502 100644 --- a/src/solidlsp/language_servers/pyright_language_server/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_language_server/pyright_server.py @@ -2,25 +2,22 @@ Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. """ -import asyncio -import json import logging import os import pathlib import re -from contextlib import asynccontextmanager -from typing import AsyncIterator, Tuple +import threading from overrides import override -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger +from solidlsp.ls import SolidLanguageServer -class PyrightServer(LanguageServer): +class PyrightServer(SolidLanguageServer): """ Provides Python specific instantiation of the LanguageServer class using Pyright. Contains various configurations and settings specific to Python. @@ -39,9 +36,9 @@ class PyrightServer(LanguageServer): ProcessLaunchInfo(cmd="python -m pyright.langserver --stdio", cwd=repository_root_path), "python", ) - + # Event to signal when initial workspace analysis is complete - self.analysis_complete = asyncio.Event() + self.analysis_complete = threading.Event() self.found_source_files = False @override @@ -148,11 +145,10 @@ class PyrightServer(LanguageServer): return initialize_params - @asynccontextmanager - async def start_server(self) -> AsyncIterator["PyrightServer"]: + def _start_server(self): """ Starts the Pyright Language Server and waits for initial workspace analysis to complete. - + This prevents zombie processes by ensuring Pyright has finished its initial background tasks before we consider the server ready. @@ -167,20 +163,20 @@ class PyrightServer(LanguageServer): ``` """ - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def window_log_message(msg): + def window_log_message(msg): """ Monitor Pyright's log messages to detect when initial analysis is complete. Pyright logs "Found X source files" when it finishes scanning the workspace. """ message_text = msg.get("message", "") self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) - + # Look for "Found X source files" which indicates workspace scanning is complete # Unfortunately, pyright is unreliable and there seems to be no better way if re.search(r"Found \d+ source files?", message_text): @@ -189,7 +185,7 @@ class PyrightServer(LanguageServer): self.analysis_complete.set() self.completions_available.set() - async def check_experimental_status(params): + def check_experimental_status(params): """ Also listen for experimental/serverStatus as a backup signal """ @@ -209,38 +205,34 @@ class PyrightServer(LanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - async with super().start_server(): - self.logger.log("Starting pyright-langserver server process", logging.INFO) - await self.server.start() + self.logger.log("Starting pyright-langserver server process", logging.INFO) + self.server.start() - # Send proper initialization parameters - initialize_params = self._get_initialize_params(self.repository_root_path) + # Send proper initialization parameters + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to pyright server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) + self.logger.log( + "Sending initialize request from LSP client to pyright server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) - # Verify that the server supports our required features - assert "textDocumentSync" in init_response["capabilities"] - assert "completionProvider" in init_response["capabilities"] - assert "definitionProvider" in init_response["capabilities"] + # Verify that the server supports our required features + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] - # Complete the initialization handshake - self.server.notify.initialized({}) - - # Wait for Pyright to complete its initial workspace analysis - # This prevents zombie processes by ensuring background tasks finish - self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) - try: - await asyncio.wait_for(self.analysis_complete.wait(), timeout=1.0) - self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) - except asyncio.TimeoutError: - self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) - # Fallback: assume analysis is complete after timeout - self.analysis_complete.set() - self.completions_available.set() + # Complete the initialization handshake + self.server.notify.initialized({}) - yield self \ No newline at end of file + # Wait for Pyright to complete its initial workspace analysis + # This prevents zombie processes by ensuring background tasks finish + self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) + if self.analysis_complete.wait(timeout=5.0): + self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) + else: + self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) + # Fallback: assume analysis is complete after timeout + self.analysis_complete.set() + self.completions_available.set() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index bd1eca2d..bafd33c2 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -68,7 +68,7 @@ class SolidLanguageServer(ABC): PyrightServer, ) - return SolidPyrightServer(config, logger, repository_root_path) + return PyrightServer(config, logger, repository_root_path) # It used to be jedi, but pyright is a bit faster, and also more actively maintained # Keeping the previous code for reference # from multilspy.language_servers.jedi_language_server.jedi_server import ( @@ -1633,224 +1633,3 @@ class SolidLanguageServer(ABC): def is_running(self) -> bool: return self.server.is_running() - - -class SolidPyrightServer(SolidLanguageServer): - """ - Provides Python specific instantiation of the LanguageServer class using Pyright. - Contains various configurations and settings specific to Python. - """ - def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): - """ - Creates a PyrightServer instance. This class is not meant to be instantiated directly. - Use LanguageServer.create() instead. - """ - super().__init__( - config, - logger, - repository_root_path, - # Note 1: we can also use `pyright-langserver --stdio` but it requires pyright to be installed with npm - # Note 2: we can also use `bpyright-langserver --stdio` if we ever are unhappy with pyright - ProcessLaunchInfo(cmd="python -m pyright.langserver --stdio", cwd=repository_root_path), - "python", - ) - - # Event to signal when initial workspace analysis is complete - self.analysis_complete = threading.Event() - self.found_source_files = False - - @override - def is_ignored_dirname(self, dirname: str) -> bool: - return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] - - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize params for the Pyright Language Server. - """ - # Create basic initialization parameters - initialize_params: InitializeParams = { # type: ignore - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": pathlib.Path(repository_absolute_path).as_uri(), - "initializationOptions": { - "exclude": [ - "**/__pycache__", - "**/.venv", - "**/.env", - "**/build", - "**/dist", - "**/.pixi", - ], - "reportMissingImports": "error", - }, - "capabilities": { - "workspace": { - "applyEdit": True, - "workspaceEdit": {"documentChanges": True}, - "didChangeConfiguration": {"dynamicRegistration": True}, - "didChangeWatchedFiles": {"dynamicRegistration": True}, - "symbol": { - "dynamicRegistration": True, - "symbolKind": { - "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] - }, - }, - "executeCommand": {"dynamicRegistration": True}, - }, - "textDocument": { - "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, - "completion": { - "dynamicRegistration": True, - "contextSupport": True, - "completionItem": { - "snippetSupport": True, - "commitCharactersSupport": True, - "documentationFormat": ["markdown", "plaintext"], - "deprecatedSupport": True, - "preselectSupport": True, - }, - "completionItemKind": { - "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] - }, - }, - "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, - "signatureHelp": { - "dynamicRegistration": True, - "signatureInformation": { - "documentationFormat": ["markdown", "plaintext"], - "parameterInformation": {"labelOffsetSupport": True}, - }, - }, - "definition": {"dynamicRegistration": True}, - "references": {"dynamicRegistration": True}, - "documentHighlight": {"dynamicRegistration": True}, - "documentSymbol": { - "dynamicRegistration": True, - "symbolKind": { - "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] - }, - "hierarchicalDocumentSymbolSupport": True, - }, - "codeAction": { - "dynamicRegistration": True, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "", - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports", - ] - } - }, - }, - "codeLens": {"dynamicRegistration": True}, - "formatting": {"dynamicRegistration": True}, - "rangeFormatting": {"dynamicRegistration": True}, - "onTypeFormatting": {"dynamicRegistration": True}, - "rename": {"dynamicRegistration": True}, - "publishDiagnostics": {"relatedInformation": True}, - }, - }, - "workspaceFolders": [ - {"uri": pathlib.Path(repository_absolute_path).as_uri(), "name": os.path.basename(repository_absolute_path)} - ], - } - - return initialize_params - - def _start_server(self): - """ - Starts the Pyright Language Server and waits for initial workspace analysis to complete. - - This prevents zombie processes by ensuring Pyright has finished its initial background - tasks before we consider the server ready. - - Usage: - ``` - async with lsp.start_server(): - # LanguageServer has been initialized and workspace analysis is complete - await lsp.request_definition(...) - await lsp.request_references(...) - # Shutdown the LanguageServer on exit from scope - # LanguageServer has been shutdown cleanly - ``` - """ - - def execute_client_command_handler(params): - return [] - - def do_nothing(params): - return - - def window_log_message(msg): - """ - Monitor Pyright's log messages to detect when initial analysis is complete. - Pyright logs "Found X source files" when it finishes scanning the workspace. - """ - message_text = msg.get("message", "") - self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) - - # Look for "Found X source files" which indicates workspace scanning is complete - # Unfortunately, pyright is unreliable and there seems to be no better way - if re.search(r"Found \d+ source files?", message_text): - self.logger.log("Pyright workspace scanning complete", logging.INFO) - self.found_source_files = True - self.analysis_complete.set() - self.completions_available.set() - - def check_experimental_status(params): - """ - Also listen for experimental/serverStatus as a backup signal - """ - if params.get("quiescent") == True: - self.logger.log("Received experimental/serverStatus with quiescent=true", logging.INFO) - if not self.found_source_files: - self.analysis_complete.set() - self.completions_available.set() - - # Set up notification handlers - self.server.on_request("client/registerCapability", do_nothing) - self.server.on_notification("language/status", do_nothing) - self.server.on_notification("window/logMessage", window_log_message) - self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) - self.server.on_notification("$/progress", do_nothing) - self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.server.on_notification("language/actionableNotification", do_nothing) - self.server.on_notification("experimental/serverStatus", check_experimental_status) - - self.logger.log("Starting pyright-langserver server process", logging.INFO) - self.server.start() - - # Send proper initialization parameters - initialize_params = self._get_initialize_params(self.repository_root_path) - - self.logger.log( - "Sending initialize request from LSP client to pyright server and awaiting response", - logging.INFO, - ) - init_response = self.server.send.initialize(initialize_params) - self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) - - # Verify that the server supports our required features - assert "textDocumentSync" in init_response["capabilities"] - assert "completionProvider" in init_response["capabilities"] - assert "definitionProvider" in init_response["capabilities"] - - # Complete the initialization handshake - self.server.notify.initialized({}) - - # Wait for Pyright to complete its initial workspace analysis - # This prevents zombie processes by ensuring background tasks finish - self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) - if self.analysis_complete.wait(timeout=5.0): - self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) - else: - self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) - # Fallback: assume analysis is complete after timeout - self.analysis_complete.set() - self.completions_available.set() From ce6a68080a1705c55f9b7d4c1342a41f3cc1ca62 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 14:59:36 +0200 Subject: [PATCH 06/30] Adapt EclipseJDTLS to solidlsp --- .../eclipse_jdtls/eclipse_jdtls.py | 102 ++++++++---------- src/solidlsp/ls.py | 21 ++-- 2 files changed, 54 insertions(+), 69 deletions(-) diff --git a/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py index a88dc1b6..1b92fc4d 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls/eclipse_jdtls.py @@ -2,7 +2,6 @@ Provides Java specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Java. """ -import asyncio import dataclasses import json import logging @@ -10,21 +9,20 @@ import os import pathlib import shutil import stat +import threading import uuid -from contextlib import asynccontextmanager -from typing import AsyncIterator +from pathlib import PurePath from overrides import override -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_settings import MultilspySettings from multilspy.multilspy_utils import FileUtils from multilspy.multilspy_utils import PlatformUtils -from pathlib import PurePath +from solidlsp.ls import SolidLanguageServer @dataclasses.dataclass @@ -43,7 +41,7 @@ class RuntimeDependencyPaths: intellisense_members_path: str -class EclipseJDTLS(LanguageServer): +class EclipseJDTLS(SolidLanguageServer): """ The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class """ @@ -135,9 +133,9 @@ class EclipseJDTLS(LanguageServer): ] ) - self.service_ready_event = asyncio.Event() - self.intellicode_enable_command_available = asyncio.Event() - self.initialize_searcher_command_available = asyncio.Event() + self.service_ready_event = threading.Event() + self.intellicode_enable_command_available = threading.Event() + self.initialize_searcher_command_available = threading.Event() super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java") @@ -324,23 +322,12 @@ class EclipseJDTLS(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["EclipseJDTLS"]: + def _start_server(self): """ - Starts the Eclipse JDTLS Language Server, waits for the server to be ready and yields the LanguageServer instance. - - Usage: - ``` - async with lsp.start_server(): - # LanguageServer has been initialized and ready to serve requests - await lsp.request_definition(...) - await lsp.request_references(...) - # Shutdown the LanguageServer on exit from scope - # LanguageServer has been shutdown - ``` + Starts the Eclipse JDTLS Language Server """ - async def register_capability_handler(params): + def register_capability_handler(params): assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "textDocument/completion": @@ -358,22 +345,22 @@ class EclipseJDTLS(LanguageServer): self.intellicode_enable_command_available.set() return - async def lang_status_handler(params): + def lang_status_handler(params): # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self.service_ready_event.set() - async def execute_client_command_handler(params): + def execute_client_command_handler(params): assert params["command"] == "_java.reloadBundles.command" assert params["arguments"] == [] return [] - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - async def do_nothing(params): + def do_nothing(params): return self.server.on_request("client/registerCapability", register_capability_handler) @@ -384,39 +371,36 @@ class EclipseJDTLS(LanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) - async with super().start_server(): - self.logger.log("Starting EclipseJDTLS server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting EclipseJDTLS server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 - assert "completionProvider" not in init_response["capabilities"] - assert "executeCommandProvider" not in init_response["capabilities"] + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" not in init_response["capabilities"] + assert "executeCommandProvider" not in init_response["capabilities"] - self.server.notify.initialized({}) + self.server.notify.initialized({}) - self.server.notify.workspace_did_change_configuration( - {"settings": initialize_params["initializationOptions"]["settings"]} - ) + self.server.notify.workspace_did_change_configuration( + {"settings": initialize_params["initializationOptions"]["settings"]} + ) - await self.intellicode_enable_command_available.wait() + self.intellicode_enable_command_available.wait() - java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path - assert os.path.exists(java_intellisense_members_path) - intellicode_enable_result = await self.server.send.execute_command( - { - "command": "java.intellicode.enable", - "arguments": [True, java_intellisense_members_path], - } - ) - assert intellicode_enable_result + java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path + assert os.path.exists(java_intellisense_members_path) + intellicode_enable_result = self.server.send.execute_command( + { + "command": "java.intellicode.enable", + "arguments": [True, java_intellisense_members_path], + } + ) + assert intellicode_enable_result - # TODO: Add comments about why we wait here, and how this can be optimized - await self.service_ready_event.wait() - - yield self + # TODO: Add comments about why we wait here, and how this can be optimized + self.service_ready_event.wait() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index bafd33c2..eeb96ada 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -67,7 +67,6 @@ class SolidLanguageServer(ABC): from solidlsp.language_servers.pyright_language_server.pyright_server import ( PyrightServer, ) - return PyrightServer(config, logger, repository_root_path) # It used to be jedi, but pyright is a bit faster, and also more actively maintained # Keeping the previous code for reference @@ -76,53 +75,55 @@ class SolidLanguageServer(ABC): # ) # return JediServer(config, logger, repository_root_path) + elif config.code_language == Language.JAVA: from solidlsp.language_servers.eclipse_jdtls.eclipse_jdtls import ( EclipseJDTLS, ) - return EclipseJDTLS(config, logger, repository_root_path) + elif config.code_language == Language.KOTLIN: from solidlsp.language_servers.kotlin_language_server.kotlin_language_server import ( KotlinLanguageServer, ) - return KotlinLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.RUST: from solidlsp.language_servers.rust_analyzer.rust_analyzer import ( RustAnalyzer, ) - return RustAnalyzer(config, logger, repository_root_path) + elif config.code_language == Language.CSHARP: from solidlsp.language_servers.omnisharp.omnisharp import OmniSharp - return OmniSharp(config, logger, repository_root_path) + elif config.code_language in [Language.TYPESCRIPT, Language.JAVASCRIPT]: from solidlsp.language_servers.typescript_language_server.typescript_language_server import ( TypeScriptLanguageServer, ) return TypeScriptLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.GO: from solidlsp.language_servers.gopls.gopls import Gopls - return Gopls(config, logger, repository_root_path) + elif config.code_language == Language.RUBY: from solidlsp.language_servers.solargraph.solargraph import Solargraph - return Solargraph(config, logger, repository_root_path) + elif config.code_language == Language.DART: from solidlsp.language_servers.dart_language_server.dart_language_server import DartLanguageServer - return DartLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.CPP: from solidlsp.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer - return ClangdLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.PHP: from solidlsp.language_servers.intelephense.intelephense import Intelephense - return Intelephense(config, logger, repository_root_path) + else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) raise MultilspyException(f"Language {config.code_language} is not supported") From a271dbc818baa7e96740d59ca04c0efb9c7903f8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:01:41 +0200 Subject: [PATCH 07/30] Adapt KotlinLanguageServer to solidlsp --- .../kotlin_language_server.py | 77 ++++++++----------- 1 file changed, 30 insertions(+), 47 deletions(-) diff --git a/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py index ca4e8666..88e71518 100644 --- a/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server/kotlin_language_server.py @@ -2,23 +2,20 @@ Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. """ -import asyncio import dataclasses import json import logging import os -import stat import pathlib -from contextlib import asynccontextmanager -from typing import AsyncIterator +import stat -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_utils import FileUtils from multilspy.multilspy_utils import PlatformUtils +from solidlsp.ls import SolidLanguageServer @dataclasses.dataclass @@ -31,7 +28,7 @@ class KotlinRuntimeDependencyPaths: kotlin_executable_path: str -class KotlinLanguageServer(LanguageServer): +class KotlinLanguageServer(SolidLanguageServer): """ Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. """ @@ -167,28 +164,17 @@ class KotlinLanguageServer(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["KotlinLanguageServer"]: + def _start_server(self): """ - Starts the Kotlin Language Server, waits for the server to be ready and yields the LanguageServer instance. - - Usage: - ``` - async with lsp.start_server(): - # LanguageServer has been initialized and ready to serve requests - await lsp.request_definition(...) - await lsp.request_references(...) - # Shutdown the LanguageServer on exit from scope - # LanguageServer has been shutdown - ``` + Starts the Kotlin Language Server """ - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) self.server.on_request("client/registerCapability", do_nothing) @@ -199,29 +185,26 @@ class KotlinLanguageServer(LanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) - async with super().start_server(): - self.logger.log("Starting Kotlin server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting Kotlin server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) - capabilities = init_response["capabilities"] - assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" - assert "hoverProvider" in capabilities, "Server must support hover" - assert "completionProvider" in capabilities, "Server must support code completion" - assert "signatureHelpProvider" in capabilities, "Server must support signature help" - assert "definitionProvider" in capabilities, "Server must support go to definition" - assert "referencesProvider" in capabilities, "Server must support find references" - assert "documentSymbolProvider" in capabilities, "Server must support document symbols" - assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" - assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" - - self.server.notify.initialized({}) - self.completions_available.set() + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + assert "hoverProvider" in capabilities, "Server must support hover" + assert "completionProvider" in capabilities, "Server must support code completion" + assert "signatureHelpProvider" in capabilities, "Server must support signature help" + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" + assert "semanticTokensProvider" in capabilities, "Server must support semantic tokens" - yield self + self.server.notify.initialized({}) + self.completions_available.set() From befd13b0ea0f8e1341fc1c9ad968ab291fa82ce4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:05:57 +0200 Subject: [PATCH 08/30] Adapt OmniSharp to solidlsp --- .../language_servers/omnisharp/omnisharp.py | 101 ++++++++---------- 1 file changed, 44 insertions(+), 57 deletions(-) diff --git a/src/solidlsp/language_servers/omnisharp/omnisharp.py b/src/solidlsp/language_servers/omnisharp/omnisharp.py index df3cf0cd..a449a985 100644 --- a/src/solidlsp/language_servers/omnisharp/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp/omnisharp.py @@ -2,24 +2,23 @@ Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. """ -import asyncio import json import logging import os import pathlib import stat -from contextlib import asynccontextmanager -from typing import AsyncIterator, Iterable +import threading +from typing import Iterable from overrides import override -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig from multilspy.multilspy_exceptions import MultilspyException +from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_utils import FileUtils, PlatformUtils, PlatformId, DotnetVersion +from solidlsp.ls import SolidLanguageServer def breadth_first_file_scan(root) -> Iterable[str]: @@ -55,7 +54,7 @@ def find_least_depth_sln_file(root_dir) -> str | None: return None -class OmniSharp(LanguageServer): +class OmniSharp(SolidLanguageServer): """ Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. """ @@ -107,8 +106,9 @@ class OmniSharp(LanguageServer): config, logger, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp" ) - self.definition_available = asyncio.Event() - self.references_available = asyncio.Event() + self.server_ready = threading.Event() + self.definition_available = threading.Event() + self.references_available = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: @@ -205,22 +205,12 @@ class OmniSharp(LanguageServer): return omnisharp_executable_path, razor_omnisharp_dll_path - @asynccontextmanager - async def start_server(self) -> AsyncIterator["OmniSharp"]: + def _start_server(self): """ - Starts the Omnisharp Language Server, waits for the server to be ready and yields the LanguageServer instance. - - Usage: - ``` - async with lsp.start_server(): - # LanguageServer has been initialized and ready to serve requests - await lsp.request_definition(...) - await lsp.request_references(...) - # Shutdown the LanguageServer on exit from scope - # LanguageServer has been shutdown + Starts the Omnisharp Language Server """ - async def register_capability_handler(params): + def register_capability_handler(params): assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "textDocument/definition": @@ -230,7 +220,7 @@ class OmniSharp(LanguageServer): if registration["method"] == "textDocument/completion": self.completions_available.set() - async def lang_status_handler(params): + def lang_status_handler(params): # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? @@ -238,20 +228,20 @@ class OmniSharp(LanguageServer): # self.service_ready_event.set() pass - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def check_experimental_status(params): + def check_experimental_status(params): if params["quiescent"] == True: self.server_ready.set() - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - async def workspace_configuration_handler(params): + def workspace_configuration_handler(params): # TODO: We do not know the appropriate way to handle this request. Should ideally contact the OmniSharp dev team return [ { @@ -373,34 +363,31 @@ class OmniSharp(LanguageServer): self.server.on_notification("experimental/serverStatus", check_experimental_status) self.server.on_request("workspace/configuration", workspace_configuration_handler) - async with super().start_server(): - self.logger.log("Starting OmniSharp server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting OmniSharp server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - self.server.notify.initialized({}) - with open(os.path.join(os.path.dirname(__file__), "workspace_did_change_configuration.json"), "r", encoding="utf-8") as f: - self.server.notify.workspace_did_change_configuration({ - "settings": json.load(f) - }) - assert "capabilities" in init_response - if ( - "definitionProvider" in init_response["capabilities"] - and init_response["capabilities"]["definitionProvider"] - ): - self.definition_available.set() - if ( - "referencesProvider" in init_response["capabilities"] - and init_response["capabilities"]["referencesProvider"] - ): - self.references_available.set() + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + self.server.notify.initialized({}) + with open(os.path.join(os.path.dirname(__file__), "workspace_did_change_configuration.json"), "r", encoding="utf-8") as f: + self.server.notify.workspace_did_change_configuration({ + "settings": json.load(f) + }) + assert "capabilities" in init_response + if ( + "definitionProvider" in init_response["capabilities"] + and init_response["capabilities"]["definitionProvider"] + ): + self.definition_available.set() + if ( + "referencesProvider" in init_response["capabilities"] + and init_response["capabilities"]["referencesProvider"] + ): + self.references_available.set() - await self.definition_available.wait() - await self.references_available.wait() - - yield self + self.definition_available.wait() + self.references_available.wait() From cf7e4eadcca78bef3de19eebfb96431106940e99 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:09:14 +0200 Subject: [PATCH 09/30] Adapt RustAnalyzer to solidlsp --- .../rust_analyzer/rust_analyzer.py | 86 ++++++++----------- 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py index 65f3598f..eb072c49 100644 --- a/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer/rust_analyzer.py @@ -2,27 +2,25 @@ Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. """ -import asyncio import json import logging import os -import stat import pathlib -from contextlib import asynccontextmanager -from typing import AsyncIterator +import stat +import threading from overrides import override -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_utils import FileUtils from multilspy.multilspy_utils import PlatformUtils +from solidlsp.ls import SolidLanguageServer -class RustAnalyzer(LanguageServer): +class RustAnalyzer(SolidLanguageServer): """ Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. """ @@ -39,8 +37,11 @@ class RustAnalyzer(LanguageServer): ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), "rust", ) - self.server_ready = asyncio.Event() - + self.server_ready = threading.Event() + self.service_ready_event = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() + @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["target"] @@ -108,22 +109,12 @@ class RustAnalyzer(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["RustAnalyzer"]: + def _start_server(self): """ - Starts the Rust Analyzer Language Server, waits for the server to be ready and yields the LanguageServer instance. - - Usage: - ``` - async with lsp.start_server(): - # LanguageServer has been initialized and ready to serve requests - await lsp.request_definition(...) - await lsp.request_references(...) - # Shutdown the LanguageServer on exit from scope - # LanguageServer has been shutdown + Starts the Rust Analyzer Language Server """ - async def register_capability_handler(params): + def register_capability_handler(params): assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": @@ -131,24 +122,24 @@ class RustAnalyzer(LanguageServer): self.resolve_main_method_available.set() return - async def lang_status_handler(params): + def lang_status_handler(params): # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self.service_ready_event.set() - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def check_experimental_status(params): + def check_experimental_status(params): if params["quiescent"] == True: self.server_ready.set() - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) self.server.on_request("client/registerCapability", register_capability_handler) @@ -160,26 +151,23 @@ class RustAnalyzer(LanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - async with super().start_server(): - self.logger.log("Starting RustAnalyzer server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting RustAnalyzer server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 - assert "completionProvider" in init_response["capabilities"] - assert init_response["capabilities"]["completionProvider"] == { - "resolveProvider": True, - "triggerCharacters": [":", ".", "'", "("], - "completionItem": {"labelDetailsSupport": True}, - } - self.server.notify.initialized({}) - self.completions_available.set() + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "resolveProvider": True, + "triggerCharacters": [":", ".", "'", "("], + "completionItem": {"labelDetailsSupport": True}, + } + self.server.notify.initialized({}) + self.completions_available.set() - await self.server_ready.wait() - - yield self + self.server_ready.wait() From 2c1c26ae3f0eff9d64b88106a12dc9be04795834 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:15:08 +0200 Subject: [PATCH 10/30] Adapt TypeScriptLanguageServer to solidlsp --- .../typescript_language_server.py | 77 +++++++++---------- 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py index 479936c9..e17a0987 100644 --- a/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server/typescript_language_server.py @@ -2,25 +2,23 @@ Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. """ -import asyncio import json import logging import os import pathlib import shutil import subprocess -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager +import threading from time import sleep from overrides import override -from multilspy.language_server import LanguageServer from multilspy.lsp_protocol_handler.lsp_types import InitializeParams from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_utils import PlatformId, PlatformUtils +from solidlsp.ls import SolidLanguageServer # Platform-specific imports if os.name != 'nt': # Unix-like systems @@ -38,7 +36,7 @@ if not PlatformUtils.get_platform_id().value.startswith("win"): import pwd -class TypeScriptLanguageServer(LanguageServer): +class TypeScriptLanguageServer(SolidLanguageServer): """ Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. """ @@ -55,7 +53,8 @@ class TypeScriptLanguageServer(LanguageServer): ProcessLaunchInfo(cmd=ts_lsp_executable_path, cwd=repository_root_path), "typescript", ) - self.server_ready = asyncio.Event() + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() @override def is_ignored_dirname(self, dirname: str) -> bool: @@ -153,8 +152,7 @@ class TypeScriptLanguageServer(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["TypeScriptLanguageServer"]: + def _start_server(self): """ Starts the TypeScript Language Server, waits for the server to be ready and yields the LanguageServer instance. @@ -168,7 +166,7 @@ class TypeScriptLanguageServer(LanguageServer): # LanguageServer has been shutdown """ - async def register_capability_handler(params): + def register_capability_handler(params): assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": @@ -178,17 +176,17 @@ class TypeScriptLanguageServer(LanguageServer): # self.resolve_main_method_available.set() return - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - async def check_experimental_status(params): + def check_experimental_status(params): """ Also listen for experimental/serverStatus as a backup signal """ @@ -203,43 +201,40 @@ class TypeScriptLanguageServer(LanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - async with super().start_server(): - self.logger.log("Starting TypeScript server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting TypeScript server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) - # TypeScript-specific capability checks - assert init_response["capabilities"]["textDocumentSync"] == 2 - assert "completionProvider" in init_response["capabilities"] - assert init_response["capabilities"]["completionProvider"] == { - "triggerCharacters": ['.', '"', "'", '/', '@', '<'], - "resolveProvider": True - } + # TypeScript-specific capability checks + assert init_response["capabilities"]["textDocumentSync"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": ['.', '"', "'", '/', '@', '<'], + "resolveProvider": True + } - self.server.notify.initialized({}) - try: - await asyncio.wait_for(self.server_ready.wait(), timeout=1.0) - except asyncio.TimeoutError: - self.logger.log("Timeout waiting for TypeScript server to become ready, proceeding anyway", logging.INFO) - # Fallback: assume server is ready after timeout - self.server_ready.set() - self.completions_available.set() - - yield self + self.server.notify.initialized({}) + if self.server_ready.wait(timeout=1.0): + self.logger.log("TypeScript server is ready", logging.INFO) + else: + self.logger.log("Timeout waiting for TypeScript server to become ready, proceeding anyway", logging.INFO) + # Fallback: assume server is ready after timeout + self.server_ready.set() + self.completions_available.set() @override # For some reason, the LS may need longer to process this, so we just retry - async def _send_references_request(self, relative_file_path: str, line: int, column: int): + def _send_references_request(self, relative_file_path: str, line: int, column: int): # TODO: The LS doesn't return references contained in other files if it doesn't sleep. This is # despite the LS having processed requests already. I don't know what causes this, but sleeping # one second helps. It may be that sleeping only once is enough but that's hard to reliably test. # It may be that even this 1sec is not enough in larger TS projects, at some point we should find what # causes this and solve it. sleep(1) - return await super()._send_references_request(relative_file_path, line, column) + return super()._send_references_request(relative_file_path, line, column) From 1bbd5b82ac93db7cdf14cd09d954ac779c263a3c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:18:13 +0200 Subject: [PATCH 11/30] Adapt Gopls to solidlsp --- src/solidlsp/language_servers/gopls/gopls.py | 63 +++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/src/solidlsp/language_servers/gopls/gopls.py b/src/solidlsp/language_servers/gopls/gopls.py index 0e5574a0..a2ec2722 100644 --- a/src/solidlsp/language_servers/gopls/gopls.py +++ b/src/solidlsp/language_servers/gopls/gopls.py @@ -1,25 +1,20 @@ -import asyncio import json import logging import os import pathlib import subprocess -from contextlib import asynccontextmanager -from typing import AsyncIterator, List +import threading from overrides import override -from multilspy import multilspy_types -from multilspy.lsp_protocol_handler import lsp_types -from multilspy.multilspy_exceptions import MultilspyException -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import Error, ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger +from solidlsp.ls import SolidLanguageServer -class Gopls(LanguageServer): +class Gopls(SolidLanguageServer): """ Provides Go specific instantiation of the LanguageServer class using gopls. """ @@ -84,7 +79,7 @@ class Gopls(LanguageServer): ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", ) - self.server_ready = asyncio.Event() + self.server_ready = threading.Event() self.request_id = 0 def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: @@ -111,16 +106,15 @@ class Gopls(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["Gopls"]: + def _start_server(self): """Start gopls server process""" - async def register_capability_handler(params): + def register_capability_handler(params): return - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) - async def do_nothing(params): + def do_nothing(params): return self.server.on_request("client/registerCapability", register_capability_handler) @@ -128,27 +122,24 @@ class Gopls(LanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - async with super().start_server(): - self.logger.log("Starting gopls server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting gopls server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - - # Verify server capabilities - assert "textDocumentSync" in init_response["capabilities"] - assert "completionProvider" in init_response["capabilities"] - assert "definitionProvider" in init_response["capabilities"] + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) - self.server.notify.initialized({}) - self.completions_available.set() + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] - # gopls server is typically ready immediately after initialization - self.server_ready.set() - await self.server_ready.wait() + self.server.notify.initialized({}) + self.completions_available.set() - yield self + # gopls server is typically ready immediately after initialization + self.server_ready.set() + self.server_ready.wait() From ddfa68b4da0e61ec4e67a9d5027d23a3ab395a5d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:21:22 +0200 Subject: [PATCH 12/30] Adapt Solargraph to solidlsp --- .../language_servers/solargraph/solargraph.py | 91 ++++++++----------- 1 file changed, 39 insertions(+), 52 deletions(-) diff --git a/src/solidlsp/language_servers/solargraph/solargraph.py b/src/solidlsp/language_servers/solargraph/solargraph.py index 099ff9d6..d036ed1e 100644 --- a/src/solidlsp/language_servers/solargraph/solargraph.py +++ b/src/solidlsp/language_servers/solargraph/solargraph.py @@ -3,26 +3,23 @@ Provides Ruby specific instantiation of the LanguageServer class using Solargrap Contains various configurations and settings specific to Ruby. """ -import asyncio import json import logging import os +import pathlib import stat import subprocess -import pathlib -from contextlib import asynccontextmanager -from typing import AsyncIterator, override +import threading +from typing import override -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig -from multilspy.multilspy_utils import FileUtils -from multilspy.multilspy_utils import PlatformUtils, PlatformId +from multilspy.multilspy_logger import MultilspyLogger +from solidlsp.ls import SolidLanguageServer -class Solargraph(LanguageServer): +class Solargraph(SolidLanguageServer): """ Provides Ruby specific instantiation of the LanguageServer class using Solargraph. Contains various configurations and settings specific to Ruby. @@ -41,8 +38,11 @@ class Solargraph(LanguageServer): ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path), "ruby", ) - self.server_ready = asyncio.Event() - + self.server_ready = threading.Event() + self.service_ready_event = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() + @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["vendor"] @@ -115,22 +115,12 @@ class Solargraph(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["Solargraph"]: + def _start_server(self): """ - Starts the Solargraph Language Server for Ruby, waits for the server to be ready and yields the LanguageServer instance. - - Usage: - ``` - async with lsp.start_server(): - # LanguageServer has been initialized and ready to serve requests - await lsp.request_definition(...) - await lsp.request_references(...) - # Shutdown the LanguageServer on exit from scope - # LanguageServer has been shutdown + Starts the Solargraph Language Server for Ruby """ - async def register_capability_handler(params): + def register_capability_handler(params): assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": @@ -138,20 +128,20 @@ class Solargraph(LanguageServer): self.resolve_main_method_available.set() return - async def lang_status_handler(params): + def lang_status_handler(params): # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self.service_ready_event.set() - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) self.server.on_request("client/registerCapability", register_capability_handler) @@ -162,28 +152,25 @@ class Solargraph(LanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) - async with super().start_server(): - self.logger.log("Starting solargraph server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting solargraph server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - self.logger.log(f"Sending init params: {json.dumps(initialize_params, indent=4)}", logging.INFO) - init_response = await self.server.send.initialize(initialize_params) - self.logger.log(f"Received init response: {init_response}", logging.INFO) - assert init_response["capabilities"]["textDocumentSync"] == 2 - assert "completionProvider" in init_response["capabilities"] - assert init_response["capabilities"]["completionProvider"] == { - "resolveProvider": True, - "triggerCharacters": [".", ":", "@"], - } - self.server.notify.initialized({}) - self.completions_available.set() + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + self.logger.log(f"Sending init params: {json.dumps(initialize_params, indent=4)}", logging.INFO) + init_response = self.server.send.initialize(initialize_params) + self.logger.log(f"Received init response: {init_response}", logging.INFO) + assert init_response["capabilities"]["textDocumentSync"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "resolveProvider": True, + "triggerCharacters": [".", ":", "@"], + } + self.server.notify.initialized({}) + self.completions_available.set() - self.server_ready.set() - await self.server_ready.wait() - - yield self + self.server_ready.set() + self.server_ready.wait() From 50116962a0f831a3d854f24bb39262b1f88144e6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:23:26 +0200 Subject: [PATCH 13/30] Adapt ClangdLanguageServer to solidlsp --- .../clangd_language_server.py | 74 +++++++++---------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py index e28aa2c0..0ee5f471 100644 --- a/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server/clangd_language_server.py @@ -2,25 +2,23 @@ Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. """ -import asyncio import json import logging import os -import stat import pathlib -from contextlib import asynccontextmanager -from typing import AsyncIterator +import stat +import threading -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_utils import FileUtils from multilspy.multilspy_utils import PlatformUtils +from solidlsp.ls import SolidLanguageServer -class ClangdLanguageServer(LanguageServer): +class ClangdLanguageServer(SolidLanguageServer): """ Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. As the project gets bigger in size, building index will take time. Try running clangd multiple times to ensure index is built properly. @@ -39,7 +37,10 @@ class ClangdLanguageServer(LanguageServer): ProcessLaunchInfo(cmd=clangd_executable_path, cwd=repository_root_path), "cpp", ) - self.server_ready = asyncio.Event() + self.server_ready = threading.Event() + self.service_ready_event = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self.resolve_main_method_available = threading.Event() def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: """ @@ -112,8 +113,7 @@ class ClangdLanguageServer(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["ClangdLanguageServer"]: + def _start_server(self): """ Starts the Clangd Language Server, waits for the server to be ready and yields the LanguageServer instance. @@ -126,7 +126,7 @@ class ClangdLanguageServer(LanguageServer): # Shutdown the LanguageServer on exit from scope # LanguageServer has been shutdown """ - async def register_capability_handler(params): + def register_capability_handler(params): assert "registrations" in params for registration in params["registrations"]: if registration["method"] == "workspace/executeCommand": @@ -134,24 +134,24 @@ class ClangdLanguageServer(LanguageServer): self.resolve_main_method_available.set() return - async def lang_status_handler(params): + def lang_status_handler(params): # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self.service_ready_event.set() - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def check_experimental_status(params): + def check_experimental_status(params): if params["quiescent"] == True: self.server_ready.set() - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) self.server.on_request("client/registerCapability", register_capability_handler) @@ -163,28 +163,26 @@ class ClangdLanguageServer(LanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - async with super().start_server(): - self.logger.log("Starting Clangd server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting Clangd server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 - assert "completionProvider" in init_response["capabilities"] - assert init_response["capabilities"]["completionProvider"] == { - "triggerCharacters": ['.', '<', '>', ':', '"', '/', '*'], - "resolveProvider": False, - } + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": ['.', '<', '>', ':', '"', '/', '*'], + "resolveProvider": False, + } - self.server.notify.initialized({}) + self.server.notify.initialized({}) - self.completions_available.set() - # set ready flag - self.server_ready.set() - await self.server_ready.wait() + self.completions_available.set() + # set ready flag + self.server_ready.set() + self.server_ready.wait() - yield self From 027edf5b4db628eb0f6cf92c7027ff4cbecf69a8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:25:15 +0200 Subject: [PATCH 14/30] Adapt DartLanguageServer to solidlsp --- .../dart_language_server.py | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/src/solidlsp/language_servers/dart_language_server/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server/dart_language_server.py index cd8ea518..2a0c4e66 100644 --- a/src/solidlsp/language_servers/dart_language_server/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server/dart_language_server.py @@ -1,17 +1,16 @@ -from contextlib import asynccontextmanager +import json import logging import os import pathlib -import shutil import stat -from typing import AsyncIterator -from multilspy.language_server import LanguageServer + from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo -import json +from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_utils import FileUtils, PlatformUtils +from solidlsp.ls import SolidLanguageServer -class DartLanguageServer(LanguageServer): +class DartLanguageServer(SolidLanguageServer): """ Provides Dart specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Dart. """ @@ -89,22 +88,21 @@ class DartLanguageServer(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["DartLanguageServer"]: + def _start_server(self): """ Start the language server and yield when the server is ready. """ - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def check_experimental_status(params): + def check_experimental_status(params): pass - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) self.server.on_request("client/registerCapability", do_nothing) @@ -120,24 +118,21 @@ class DartLanguageServer(LanguageServer): "experimental/serverStatus", check_experimental_status ) - async with super().start_server(): - self.logger.log( - "Starting dart-language-server server process", logging.INFO - ) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request to dart-language-server", - logging.DEBUG, - ) - init_response = await self.server.send_request( - "initialize", initialize_params - ) - self.logger.log( - f"Received initialize response from dart-language-server: {init_response}", - logging.INFO, - ) + self.logger.log( + "Starting dart-language-server server process", logging.INFO + ) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log( + "Sending initialize request to dart-language-server", + logging.DEBUG, + ) + init_response = self.server.send_request( + "initialize", initialize_params + ) + self.logger.log( + f"Received initialize response from dart-language-server: {init_response}", + logging.INFO, + ) - self.server.notify.initialized({}) - - yield self + self.server.notify.initialized({}) From b44424a54b93a440c118768a5773b665cbf8307c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:36:13 +0200 Subject: [PATCH 15/30] Make timeout parameter optional --- src/solidlsp/ls.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index eeb96ada..9981ed1e 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1,4 +1,3 @@ -import asyncio import json import logging import os @@ -15,14 +14,13 @@ from typing import Dict, Iterator, List, Optional, Tuple, Union, cast, Self import pathspec import tqdm -from overrides import override from multilspy import multilspy_types from multilspy.language_server import LSPFileBuffer, GenericDocumentSymbol, ReferenceInSymbol from multilspy.lsp_protocol_handler import lsp_types from multilspy.lsp_protocol_handler import lsp_types as LSPTypes from multilspy.lsp_protocol_handler.lsp_constants import LSPConstants -from multilspy.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink, SymbolKind, InitializeParams +from multilspy.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink, SymbolKind from multilspy.lsp_protocol_handler.server import ( Error, ProcessLaunchInfo, @@ -51,7 +49,7 @@ class SolidLanguageServer(ABC): return dirname.startswith('.') @classmethod - def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str, timeout) -> "SolidLanguageServer": + def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str, timeout: Optional[float] = None) -> "SolidLanguageServer": """ Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. @@ -63,6 +61,7 @@ class SolidLanguageServer(ABC): :param logger: The logger to use. :return LanguageServer: A language specific LanguageServer instance. """ + # TODO: Apply timeout if config.code_language == Language.PYTHON: from solidlsp.language_servers.pyright_language_server.pyright_server import ( PyrightServer, From 241d3c2346748e219e58c9051c8f96a9b24d0bf6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:40:25 +0200 Subject: [PATCH 16/30] Fix too general exception handler for error during project activation --- src/serena/agent.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 6ced373f..31fc9b44 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -215,6 +215,10 @@ class ProjectConfig(ToStringMixin): return set(ToolRegistry.get_tool_class_by_name(tool_name) for tool_name in self.excluded_tools) +class ProjectNotFoundError(Exception): + pass + + @dataclass class Project: project_root: str @@ -812,7 +816,7 @@ class SerenaAgent: if project is not None: try: self.activate_project_from_path_or_name(project) - except Exception as e: + except ProjectNotFoundError as e: log.error( f"Error activating project '{project}': {e}; Note that out-of-project configurations were migrated. " "You should now pass either --project or --project ." @@ -979,7 +983,7 @@ class SerenaAgent: log.info(f"Found registered project {project_instance.project_name} at path {project_instance.project_root}.") else: if not os.path.isdir(project_root_or_name): - raise ValueError( + raise ProjectNotFoundError( f"Project '{project_root_or_name}' not found: Not a valid project name or directory. " f"Existing project names: {self.serena_config.project_names}" ) From ac172dcab6e6d1782454388ddcd88b55b96dfdc2 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 15:36:32 +0200 Subject: [PATCH 17/30] Add global flag USE_SOLID_LSP to switch from multilspy to solidlsp --- src/serena/agent.py | 15 +++++++++++---- src/serena/constants.py | 2 ++ test/conftest.py | 9 ++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 31fc9b44..6ef76ef2 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -39,7 +39,14 @@ from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_types import SymbolKind from serena import serena_version from serena.config import SerenaAgentContext, SerenaAgentMode -from serena.constants import DEFAULT_ENCODING, PROJECT_TEMPLATE_FILE, REPO_ROOT, SELENA_CONFIG_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME +from serena.constants import ( + DEFAULT_ENCODING, + PROJECT_TEMPLATE_FILE, + REPO_ROOT, + SELENA_CONFIG_TEMPLATE_FILE, + SERENA_MANAGED_DIR_NAME, + USE_SOLID_LSP, +) from serena.dashboard import MemoryLogHandler, SerenaDashboardAPI from serena.prompt_factory import PromptFactory, SerenaPromptFactory from serena.symbol import SymbolManager @@ -671,14 +678,14 @@ def create_ls_for_project( ) ls_logger = MultilspyLogger(log_level=log_level) log.info(f"Creating language server instance for {project_instance.project_root}.") - use_solid_ls = True - if use_solid_ls: - return SolidLanguageServer.create( + if USE_SOLID_LSP: + ls = SolidLanguageServer.create( multilspy_config, ls_logger, project_instance.project_root, timeout=ls_timeout, ) + return cast(SyncLanguageServer, ls) # TODO: Fix type else: return SyncLanguageServer.create( multilspy_config, diff --git a/src/serena/constants.py b/src/serena/constants.py index 4cbf39ab..86c1b31f 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -18,3 +18,5 @@ DEFAULT_MODES = ("interactive", "editing") PROJECT_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.template.yml") SELENA_CONFIG_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "serena_config.template.yml") + +USE_SOLID_LSP = True diff --git a/test/conftest.py b/test/conftest.py index 994034e2..75bc8c74 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from typing import cast import pytest from sensai.util.logging import configure @@ -7,7 +8,9 @@ from sensai.util.logging import configure from multilspy.language_server import SyncLanguageServer from multilspy.multilspy_config import Language, MultilspyConfig from multilspy.multilspy_logger import MultilspyLogger +from serena.constants import USE_SOLID_LSP from serena.util.file_system import GitignoreParser +from solidlsp.ls import SolidLanguageServer configure(level=logging.DEBUG) @@ -43,7 +46,11 @@ def create_ls( config = MultilspyConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication) logger = MultilspyLogger(log_level=log_level) - return SyncLanguageServer.create(config, logger, repo_path) + if USE_SOLID_LSP: + ls = SolidLanguageServer.create(config, logger, repo_path) + return cast(SyncLanguageServer, ls) # TODO: Fix type + else: + return SyncLanguageServer.create(config, logger, repo_path) def create_default_ls(language: Language) -> SyncLanguageServer: From 75354a30ca62825748b00d8b9f064c91f3ae0217 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 20:10:26 +0200 Subject: [PATCH 18/30] Refactoring of MCP creation, introducing SerenaMCPFactory --- src/serena/mcp.py | 309 ++++++++++++++++++++++++---------------------- 1 file changed, 160 insertions(+), 149 deletions(-) diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 9cbdc1f3..5097eb7b 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -23,12 +23,12 @@ from mcp.server.fastmcp.server import FastMCP, Settings from mcp.server.fastmcp.tools.base import Tool as MCPTool from pydantic_settings import SettingsConfigDict from sensai.util import logging -from sensai.util.helper import mark_used from serena.agent import ( ActivateProjectTool, Project, SerenaAgent, + SerenaConfig, ToolInterface, ToolRegistry, create_serena_config, @@ -66,152 +66,169 @@ class SerenaMCPRequestContext: agent: SerenaAgent -def make_tool( - tool: ToolInterface, -) -> MCPTool: - func_name = tool.get_name() - func_doc = tool.get_apply_docstring() or "" - func_arg_metadata = tool.get_apply_fn_metadata() - is_async = False - parameters = func_arg_metadata.arg_model.model_json_schema() +class SerenaMCPFactory: + def __init__(self, context: str = DEFAULT_CONTEXT, project: str | None = None): + """ + :param context: The context name or path to context file + :param project: Either an absolute path to the project directory or a name of an already registered project. + If the project passed here hasn't been registered yet, it will be registered automatically and can be activated by its name + afterward. + """ + self.context = SerenaAgentContext.load(context) - docstring = docstring_parser.parse(func_doc) + # load project (if any) + project_instance: Project | None = None + if project is not None: + # Fail early if the project cannot be loaded + log.info(f"Will activate project {project} at mcp server startup") + try: + project_instance = Project.load(project) + except Exception as e: + log.error(f"Failed to load or generate project config for {project}: {e}") + raise + else: + log.warning("No project passed at startup, you will have to activate a project yourself (by asking the agent to do so).") + self.project_instance = project_instance + self.project = project - # Mount the tool description as a combination of the docstring description and - # the return value description, if it exists. - if docstring.description: - func_doc = f"{docstring.description.strip().strip('.')}." - else: - func_doc = "" - if docstring.returns and (docstring_returns_descr := docstring.returns.description): - # Only add a space before "Returns" if func_doc is not empty - prefix = " " if func_doc else "" - func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." + self.active_tool_names = self._determine_active_tool_names(self.context, self.project_instance) - # Parse the parameter descriptions from the docstring and add pass its description - # to the parameters schema. - docstring_params = {param.arg_name: param for param in docstring.params} - parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] - for parameter, properties in parameters_properties.items(): - if (param_doc := docstring_params.get(parameter)) and param_doc.description: - param_desc = f"{param_doc.description.strip().strip('.') + '.'}" - properties["description"] = param_desc[0].upper() + param_desc[1:] + self.serena_agent_process: ProcessIsolatedSerenaAgent | None = None + self.serena_dashboard_process: ProcessIsolatedDashboard | None = None - def execute_fn(**kwargs) -> str: # type: ignore - return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) + @staticmethod + def _determine_active_tool_names(context: SerenaAgentContext, project: Project) -> set[str]: + """ + Determine the names of tools that should be included in this session based on the context. + """ + tools_excluded_in_this_session = context.get_excluded_tool_classes() - return MCPTool( - fn=execute_fn, - name=func_name, - description=func_doc, - parameters=parameters, - fn_metadata=func_arg_metadata, - is_async=is_async, - context_kwarg=None, - annotations=None, - ) - - -def create_mcp_server_and_agent( - project: str | None, - host: str = "0.0.0.0", - port: int = 8000, - context: str = DEFAULT_CONTEXT, - modes: Sequence[str] = DEFAULT_MODES, - enable_web_dashboard: bool | None = None, - enable_gui_log_window: bool | None = None, - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, - trace_lsp_communication: bool | None = None, - tool_timeout: float | None = None, -) -> tuple[FastMCP, ProcessIsolatedSerenaAgent]: - """ - Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination. - - :param project: "Either an absolute path to the project directory or a name of an already registered project. " - "If the project passed here hasn't been registered yet, it will be registered automatically and can be activated by its name " - "afterwards. - :param host: The host to bind to - :param port: The port to bind to - :param context: The context name or path to context file - :param modes: List of mode names or paths to mode files - :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. - :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. - If not specified, will take the value from the serena configuration. - :param log_level: Log level. If not specified, will take the value from the serena configuration. - :param trace_lsp_communication: Whether to trace the communication between Serena and the language servers. - This is useful for debugging language server issues. - :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. - """ - mcp: FastMCP | None = None - context_instance = SerenaAgentContext.load(context) - modes_instances = [SerenaAgentMode.load(mode) for mode in modes] - - is_ide_assistant = context_instance.name == RegisteredContext.IDE_ASSISTANT.value - - project_instance: Project | None = None - if project is not None: - # Fail early if the project cannot be loaded - log.info(f"Will activate project {project} at mcp server startup") - try: - project_instance = Project.load(project) - except Exception as e: - log.error(f"Failed to load or generate project config for {project}: {e}") - raise - else: - log.warning("No project passed at startup, you will have to activate a project yourself (by asking the agent to do so).") - - tools_excluded_in_this_session = context_instance.get_excluded_tool_classes() - if is_ide_assistant and project_instance is not None: - tools_excluded_in_this_session.extend(project_instance.project_config.get_excluded_tool_classes()) # if a project has been loaded, it will be activated at startup and in ide-assistant context, # we assume that no other project will be activated in this session. # Therefore, we exclude the activate project tool - tools_excluded_in_this_session.append(ActivateProjectTool) - tool_names_excluded_in_this_session = {tool.get_name_from_cls() for tool in tools_excluded_in_this_session} + is_ide_assistant = context.name == RegisteredContext.IDE_ASSISTANT.value + if is_ide_assistant and project is not None: + tools_excluded_in_this_session.extend(project.project_config.get_excluded_tool_classes()) + tools_excluded_in_this_session.append(ActivateProjectTool) - all_tool_names = set(ToolRegistry.get_tool_names()) - tool_names_included_in_this_session = all_tool_names - tool_names_excluded_in_this_session + tool_names_excluded_in_this_session = {tool.get_name_from_cls() for tool in tools_excluded_in_this_session} - try: - # Start agent and dashboard processes (the latter only if enabled) - serena_dashboard_process = None - serena_config = create_serena_config( - enable_web_dashboard=enable_web_dashboard, - enable_gui_log_window=enable_gui_log_window, - log_level=log_level, - trace_lsp_communication=trace_lsp_communication, - tool_timeout=tool_timeout, - ) - if serena_config.web_dashboard: - serena_dashboard_process = ProcessIsolatedDashboard(tool_names=sorted(tool_names_included_in_this_session)) - serena_agent_process = ProcessIsolatedSerenaAgent( - project=project, serena_config=serena_config, modes=modes_instances, context=context_instance + all_tool_names = set(ToolRegistry.get_tool_names()) + tool_names_included_in_this_session = all_tool_names - tool_names_excluded_in_this_session + return tool_names_included_in_this_session + + def _make_tool(self, tool: ToolInterface) -> MCPTool: + func_name = tool.get_name() + func_doc = tool.get_apply_docstring() or "" + func_arg_metadata = tool.get_apply_fn_metadata() + is_async = False + parameters = func_arg_metadata.arg_model.model_json_schema() + + docstring = docstring_parser.parse(func_doc) + + # Mount the tool description as a combination of the docstring description and + # the return value description, if it exists. + if docstring.description: + func_doc = f"{docstring.description.strip().strip('.')}." + else: + func_doc = "" + if docstring.returns and (docstring_returns_descr := docstring.returns.description): + # Only add a space before "Returns" if func_doc is not empty + prefix = " " if func_doc else "" + func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." + + # Parse the parameter descriptions from the docstring and add pass its description + # to the parameter schema. + docstring_params = {param.arg_name: param for param in docstring.params} + parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] + for parameter, properties in parameters_properties.items(): + if (param_doc := docstring_params.get(parameter)) and param_doc.description: + param_desc = f"{param_doc.description.strip().strip('.') + '.'}" + properties["description"] = param_desc[0].upper() + param_desc[1:] + + def execute_fn(**kwargs) -> str: # type: ignore + return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) + + return MCPTool( + fn=execute_fn, + name=func_name, + description=func_doc, + parameters=parameters, + fn_metadata=func_arg_metadata, + is_async=is_async, + context_kwarg=None, + annotations=None, ) - except Exception as e: - show_fatal_exception_safe(e) - raise - - def update_tools() -> None: - """Update the tools in the MCP server - adapted for process isolation.""" - nonlocal mcp, serena_agent_process - - # Get tool names from agent - # Tools may change as a result of project or mode activation. - # NOTE: While we could pass updated tool information on to the MCP server via the callback, Claude Desktop does not, - # unfortunately, query for changed tools. It only queries for changed resources and prompts regularly, - # so we need to register all potentially active tools at startup, unfortunately. + def _set_mcp_tools(self, mcp: FastMCP) -> None: + """Update the tools in the MCP server""" if mcp is not None: mcp._tool_manager._tools = {} - for tool_name in tool_names_included_in_this_session: - process_isolated_tool = ProcessIsolatedTool(process_agent=serena_agent_process, tool_name=tool_name) - mcp_tool = make_tool(process_isolated_tool) + for tool_name in self.active_tool_names: + process_isolated_tool = ProcessIsolatedTool(process_agent=self.serena_agent_process, tool_name=tool_name) + mcp_tool = self._make_tool(process_isolated_tool) mcp._tool_manager._tools[tool_name] = mcp_tool + def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: + if serena_config.web_dashboard: + self.serena_dashboard_process = ProcessIsolatedDashboard(tool_names=sorted(self.active_tool_names)) + self.serena_agent_process = ProcessIsolatedSerenaAgent( + project=self.project, serena_config=serena_config, modes=modes, context=self.context + ) + + def create_mcp_server( + self, + host: str = "0.0.0.0", + port: int = 8000, + modes: Sequence[str] = DEFAULT_MODES, + enable_web_dashboard: bool | None = None, + enable_gui_log_window: bool | None = None, + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, + trace_lsp_communication: bool | None = None, + tool_timeout: float | None = None, + ) -> FastMCP: + """ + Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination. + + :param host: The host to bind to + :param port: The port to bind to + :param modes: List of mode names or paths to mode files + :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. + :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. + If not specified, will take the value from the serena configuration. + :param log_level: Log level. If not specified, will take the value from the serena configuration. + :param trace_lsp_communication: Whether to trace the communication between Serena and the language servers. + This is useful for debugging language server issues. + :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. + """ + try: + # Start agent and dashboard processes (the latter only if enabled) + serena_config = create_serena_config( + enable_web_dashboard=enable_web_dashboard, + enable_gui_log_window=enable_gui_log_window, + log_level=log_level, + trace_lsp_communication=trace_lsp_communication, + tool_timeout=tool_timeout, + ) + modes_instances = [SerenaAgentMode.load(mode) for mode in modes] + self._instantiate_agent(serena_config, modes_instances) + + except Exception as e: + show_fatal_exception_safe(e) + raise + + # Override model_config to disable the use of `.env` files for reading settings, because user projects are likely to contain + # `.env` files (e.g. containing LOG_LEVEL) that are not supposed to override the MCP settings; + # retain only FASTMCP_ prefix for already set environment variables. + Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") + + mcp_settings = Settings(lifespan=self.server_lifespan, host=host, port=port) + mcp = FastMCP(**mcp_settings.model_dump()) + return mcp + @asynccontextmanager - async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[None]: + async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]: """Manage server startup and shutdown lifecycle.""" - mark_used(mcp_server) def signal_handler(signum: int, frame: Any) -> None: log.info(f"Received signal {signum} in main process") @@ -220,6 +237,8 @@ def create_mcp_server_and_agent( def force_exit() -> None: time.sleep(2.0) # Wait 2 seconds for graceful shutdown log.warning("Forcing exit after timeout") + # noinspection PyProtectedMember + # noinspection PyUnresolvedReferences os._exit(1) threading.Thread(target=force_exit, daemon=True).start() @@ -228,12 +247,13 @@ def create_mcp_server_and_agent( sigint_singal = signal.signal(signal.SIGINT, signal_handler) sigterm_signal = signal.signal(signal.SIGTERM, signal_handler) - if serena_dashboard_process is not None: + if self.serena_dashboard_process is not None: log.info("Starting dashboard process") - serena_dashboard_process.start() + self.serena_dashboard_process.start() log.info("Starting serena agent process") - serena_agent_process.start() - update_tools() + self.serena_agent_process.start() + + self._set_mcp_tools(mcp_server) async def monitor_global_shutdown() -> None: """Monitor the global shutdown event and trigger local shutdown.""" @@ -264,23 +284,14 @@ def create_mcp_server_and_agent( with contextlib.suppress(asyncio.CancelledError): await monitor_task - serena_agent_process.stop() - if serena_dashboard_process is not None: - serena_dashboard_process.stop() + self.serena_agent_process.stop() + if self.serena_dashboard_process is not None: + self.serena_dashboard_process.stop() request_global_shutdown() log.info("Shutting down all processes") signal.signal(signal.SIGINT, sigint_singal) signal.signal(signal.SIGTERM, sigterm_signal) - # Override model_config to disable the use of `.env` files for reading settings, because user projects are likely to contain - # `.env` files (e.g. containing LOG_LEVEL) that are not supposed to override the MCP settings; - # retain only FASTMCP_ prefix for already set environment variables. - Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") - - mcp_settings = Settings(lifespan=server_lifespan, host=host, port=port) - mcp = FastMCP(**mcp_settings.model_dump()) - return mcp, serena_agent_process - class ProjectType(click.ParamType): name = "[PROJECT_NAME|PROJECT_PATH]" @@ -417,12 +428,12 @@ def start_mcp_server( # This is for backward compatibility with the old CLI, should be removed in the future! project_file = project_file_arg if project_file_arg is not None else project + mcp_factory = SerenaMCPFactory(context=context, project=project_file) + # Use process isolation by default to prevent asyncio event loop contamination - mcp_server, agent = create_mcp_server_and_agent( - project=project_file, + mcp_server = mcp_factory.create_mcp_server( host=host, port=port, - context=context, modes=modes, enable_web_dashboard=enable_web_dashboard, enable_gui_log_window=enable_gui_log_window, @@ -439,6 +450,6 @@ def start_mcp_server( f"Used path: {project_file}" ) - log.info(f"Starting MCP server with config:\n{agent.serena_config}.\n Log level: {agent.serena_config.log_level}") + log.info("Starting MCP server ...") mcp_server.run(transport=transport) From aba7184e2762a67af84274c7b644d3a16c955bc4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 20:40:08 +0200 Subject: [PATCH 19/30] Fix project activation by name not working anymore #210 --- src/serena/mcp.py | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 5097eb7b..c43a014e 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -9,7 +9,7 @@ import signal import sys import threading import time -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Iterator, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass from logging import Formatter, Logger, StreamHandler @@ -75,24 +75,9 @@ class SerenaMCPFactory: afterward. """ self.context = SerenaAgentContext.load(context) - - # load project (if any) - project_instance: Project | None = None - if project is not None: - # Fail early if the project cannot be loaded - log.info(f"Will activate project {project} at mcp server startup") - try: - project_instance = Project.load(project) - except Exception as e: - log.error(f"Failed to load or generate project config for {project}: {e}") - raise - else: - log.warning("No project passed at startup, you will have to activate a project yourself (by asking the agent to do so).") - self.project_instance = project_instance self.project = project - self.active_tool_names = self._determine_active_tool_names(self.context, self.project_instance) - + self.active_tool_names: list[str] | None = None self.serena_agent_process: ProcessIsolatedSerenaAgent | None = None self.serena_dashboard_process: ProcessIsolatedDashboard | None = None @@ -117,7 +102,8 @@ class SerenaMCPFactory: tool_names_included_in_this_session = all_tool_names - tool_names_excluded_in_this_session return tool_names_included_in_this_session - def _make_tool(self, tool: ToolInterface) -> MCPTool: + @staticmethod + def _make_mcp_tool(tool: ToolInterface) -> MCPTool: func_name = tool.get_name() func_doc = tool.get_apply_docstring() or "" func_arg_metadata = tool.get_apply_fn_metadata() @@ -160,21 +146,27 @@ class SerenaMCPFactory: annotations=None, ) + def _iter_tools(self) -> Iterator[ToolInterface]: + for tool_name in self.active_tool_names: + yield ProcessIsolatedTool(process_agent=self.serena_agent_process, tool_name=tool_name) + + # noinspection PyProtectedMember def _set_mcp_tools(self, mcp: FastMCP) -> None: """Update the tools in the MCP server""" if mcp is not None: mcp._tool_manager._tools = {} - for tool_name in self.active_tool_names: - process_isolated_tool = ProcessIsolatedTool(process_agent=self.serena_agent_process, tool_name=tool_name) - mcp_tool = self._make_tool(process_isolated_tool) - mcp._tool_manager._tools[tool_name] = mcp_tool + for tool in self._iter_tools(): + mcp_tool = self._make_mcp_tool(tool) + mcp._tool_manager._tools[tool.get_name()] = mcp_tool def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: - if serena_config.web_dashboard: - self.serena_dashboard_process = ProcessIsolatedDashboard(tool_names=sorted(self.active_tool_names)) + self.project_instance = serena_config.get_project(self.project) self.serena_agent_process = ProcessIsolatedSerenaAgent( project=self.project, serena_config=serena_config, modes=modes, context=self.context ) + self.active_tool_names = self._determine_active_tool_names(self.context, self.project_instance) + if serena_config.web_dashboard: + self.serena_dashboard_process = ProcessIsolatedDashboard(tool_names=sorted(self.active_tool_names)) def create_mcp_server( self, @@ -202,7 +194,6 @@ class SerenaMCPFactory: :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. """ try: - # Start agent and dashboard processes (the latter only if enabled) serena_config = create_serena_config( enable_web_dashboard=enable_web_dashboard, enable_gui_log_window=enable_gui_log_window, From d2c13fc081936f20d21e731d4852aaad896258bb Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 21:02:35 +0200 Subject: [PATCH 20/30] Add MCP factory which runs the agent and its LS in the same process again (isolation no longer required when using solidlsp) --- src/serena/agent.py | 9 ++- src/serena/mcp.py | 167 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 6ef76ef2..ef6e2264 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -736,11 +736,10 @@ class SerenaAgent: :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. The modes may adjust prompts, tool availability, and tool descriptions. :param serena_config: the Serena configuration or None to read the configuration from the default location. - :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. - :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. - If not specified, will take the value from the serena configuration. - :param log_level: Log level for the GUI log window. If not specified, will take the value from the serena configuration. - :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. + :param enable_web_dashboard: whether to enable the web dashboard; If None, will take the value from the Serena configuration. + :param enable_gui_log_window: whether to enable the GUI log window; If None, will take the value from the Serena configuration. + :param log_level: the log level for the GUI log window; If None, will take the value from the serena configuration. + :param tool_timeout: the timeout in seconds for tool execution. If None, will take the value from the serena configuration. """ # obtain serena configuration using the decoupled factory function self.serena_config = create_serena_config( diff --git a/src/serena/mcp.py b/src/serena/mcp.py index c43a014e..2764f80d 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -9,6 +9,7 @@ import signal import sys import threading import time +from abc import abstractmethod from collections.abc import AsyncIterator, Iterator, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass @@ -35,7 +36,7 @@ from serena.agent import ( show_fatal_exception_safe, ) from serena.config import RegisteredContext, SerenaAgentContext, SerenaAgentMode -from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES +from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, USE_SOLID_LSP from serena.process_isolated_agent import ( ProcessIsolatedDashboard, ProcessIsolatedSerenaAgent, @@ -77,6 +78,164 @@ class SerenaMCPFactory: self.context = SerenaAgentContext.load(context) self.project = project + @staticmethod + def _make_mcp_tool(tool: ToolInterface) -> MCPTool: + func_name = tool.get_name() + func_doc = tool.get_apply_docstring() or "" + func_arg_metadata = tool.get_apply_fn_metadata() + is_async = False + parameters = func_arg_metadata.arg_model.model_json_schema() + + docstring = docstring_parser.parse(func_doc) + + # Mount the tool description as a combination of the docstring description and + # the return value description, if it exists. + if docstring.description: + func_doc = f"{docstring.description.strip().strip('.')}." + else: + func_doc = "" + if docstring.returns and (docstring_returns_descr := docstring.returns.description): + # Only add a space before "Returns" if func_doc is not empty + prefix = " " if func_doc else "" + func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." + + # Parse the parameter descriptions from the docstring and add pass its description + # to the parameter schema. + docstring_params = {param.arg_name: param for param in docstring.params} + parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] + for parameter, properties in parameters_properties.items(): + if (param_doc := docstring_params.get(parameter)) and param_doc.description: + param_desc = f"{param_doc.description.strip().strip('.') + '.'}" + properties["description"] = param_desc[0].upper() + param_desc[1:] + + def execute_fn(**kwargs) -> str: # type: ignore + return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) + + return MCPTool( + fn=execute_fn, + name=func_name, + description=func_doc, + parameters=parameters, + fn_metadata=func_arg_metadata, + is_async=is_async, + context_kwarg=None, + annotations=None, + ) + + @abstractmethod + def _iter_tools(self) -> Iterator[ToolInterface]: + pass + + # noinspection PyProtectedMember + def _set_mcp_tools(self, mcp: FastMCP) -> None: + """Update the tools in the MCP server""" + if mcp is not None: + mcp._tool_manager._tools = {} + for tool in self._iter_tools(): + mcp_tool = self._make_mcp_tool(tool) + mcp._tool_manager._tools[tool.get_name()] = mcp_tool + + @abstractmethod + def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: + pass + + def create_mcp_server( + self, + host: str = "0.0.0.0", + port: int = 8000, + modes: Sequence[str] = DEFAULT_MODES, + enable_web_dashboard: bool | None = None, + enable_gui_log_window: bool | None = None, + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, + trace_lsp_communication: bool | None = None, + tool_timeout: float | None = None, + ) -> FastMCP: + """ + Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination. + + :param host: The host to bind to + :param port: The port to bind to + :param modes: List of mode names or paths to mode files + :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. + :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. + If not specified, will take the value from the serena configuration. + :param log_level: Log level. If not specified, will take the value from the serena configuration. + :param trace_lsp_communication: Whether to trace the communication between Serena and the language servers. + This is useful for debugging language server issues. + :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. + """ + try: + serena_config = create_serena_config( + enable_web_dashboard=enable_web_dashboard, + enable_gui_log_window=enable_gui_log_window, + log_level=log_level, + trace_lsp_communication=trace_lsp_communication, + tool_timeout=tool_timeout, + ) + modes_instances = [SerenaAgentMode.load(mode) for mode in modes] + self._instantiate_agent(serena_config, modes_instances) + + except Exception as e: + show_fatal_exception_safe(e) + raise + + # Override model_config to disable the use of `.env` files for reading settings, because user projects are likely to contain + # `.env` files (e.g. containing LOG_LEVEL) that are not supposed to override the MCP settings; + # retain only FASTMCP_ prefix for already set environment variables. + Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") + + mcp_settings = Settings(lifespan=self.server_lifespan, host=host, port=port) + mcp = FastMCP(**mcp_settings.model_dump()) + return mcp + + @asynccontextmanager + @abstractmethod + async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]: + """Manage server startup and shutdown lifecycle.""" + + +class SerenaMCPFactorySingleProcess(SerenaMCPFactory): + """ + MCP server factory where the SerenaAgent and its language server run in the same process as the MCP server + """ + + def __init__(self, context: str = DEFAULT_CONTEXT, project: str | None = None): + """ + :param context: The context name or path to context file + :param project: Either an absolute path to the project directory or a name of an already registered project. + If the project passed here hasn't been registered yet, it will be registered automatically and can be activated by its name + afterward. + """ + super().__init__(context=context, project=project) + self.agent: SerenaAgent | None = None + + def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: + self.agent = SerenaAgent(project=self.project, serena_config=serena_config, context=self.context, modes=modes) + + def _iter_tools(self) -> Iterator[ToolInterface]: + yield from self.agent.get_exposed_tool_instances() + + @asynccontextmanager + async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]: + self._set_mcp_tools(mcp_server) + yield + + +class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory): + """ + MCP server factory with process isolation for the SerenaAgent and its language server; they run in a separate process + from the MCP server. + """ + + def __init__(self, context: str = DEFAULT_CONTEXT, project: str | None = None): + """ + :param context: The context name or path to context file + :param project: Either an absolute path to the project directory or a name of an already registered project. + If the project passed here hasn't been registered yet, it will be registered automatically and can be activated by its name + afterward. + """ + super().__init__(context=context, project=project) + self.active_tool_names: list[str] | None = None self.serena_agent_process: ProcessIsolatedSerenaAgent | None = None self.serena_dashboard_process: ProcessIsolatedDashboard | None = None @@ -419,7 +578,11 @@ def start_mcp_server( # This is for backward compatibility with the old CLI, should be removed in the future! project_file = project_file_arg if project_file_arg is not None else project - mcp_factory = SerenaMCPFactory(context=context, project=project_file) + if USE_SOLID_LSP: + mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) + else: + # using multilspy requires process isolation to prevent asyncio contamination + mcp_factory = SerenaMCPFactoryWithProcessIsolation(context=context, project=project_file) # Use process isolation by default to prevent asyncio event loop contamination mcp_server = mcp_factory.create_mcp_server( From a2855bb8f995cd88e9dfed0d17848b560a432f05 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 21:09:14 +0200 Subject: [PATCH 21/30] Adapt JediServer to solidlsp --- .../jedi_language_server/jedi_server.py | 66 +++++++------------ 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/src/solidlsp/language_servers/jedi_language_server/jedi_server.py b/src/solidlsp/language_servers/jedi_language_server/jedi_server.py index 28c9d91d..9a4dbc8a 100644 --- a/src/solidlsp/language_servers/jedi_language_server/jedi_server.py +++ b/src/solidlsp/language_servers/jedi_language_server/jedi_server.py @@ -6,19 +6,17 @@ import json import logging import os import pathlib -from contextlib import asynccontextmanager -from typing import AsyncIterator, Tuple from overrides import override -from multilspy.multilspy_logger import MultilspyLogger -from multilspy.language_server import LanguageServer -from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger +from solidlsp.ls import SolidLanguageServer -class JediServer(LanguageServer): +class JediServer(SolidLanguageServer): """ Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. """ @@ -63,33 +61,22 @@ class JediServer(LanguageServer): return d - @asynccontextmanager - async def start_server(self) -> AsyncIterator["JediServer"]: + def _start_server(self): """ - Starts the JEDI Language Server, waits for the server to be ready and yields the LanguageServer instance. - - Usage: - ``` - async with lsp.start_server(): - # LanguageServer has been initialized and ready to serve requests - await lsp.request_definition(...) - await lsp.request_references(...) - # Shutdown the LanguageServer on exit from scope - # LanguageServer has been shutdown - ``` + Starts the JEDI Language Server """ - async def execute_client_command_handler(params): + def execute_client_command_handler(params): return [] - async def do_nothing(params): + def do_nothing(params): return - async def check_experimental_status(params): + def check_experimental_status(params): if params["quiescent"] == True: self.completions_available.set() - async def window_log_message(msg): + def window_log_message(msg): self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) self.server.on_request("client/registerCapability", do_nothing) @@ -101,23 +88,20 @@ class JediServer(LanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - async with super().start_server(): - self.logger.log("Starting jedi-language-server server process", logging.INFO) - await self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log("Starting jedi-language-server server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - init_response = await self.server.send.initialize(initialize_params) - assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 - assert "completionProvider" in init_response["capabilities"] - assert init_response["capabilities"]["completionProvider"] == { - "triggerCharacters": [".", "'", '"'], - "resolveProvider": True, - } + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": [".", "'", '"'], + "resolveProvider": True, + } - self.server.notify.initialized({}) - - yield self + self.server.notify.initialized({}) From c3bb2e3d772ad8daf167d8af525b207ab4612673 Mon Sep 17 00:00:00 2001 From: Serena Date: Sat, 21 Jun 2025 21:25:16 +0200 Subject: [PATCH 22/30] Change dashboard to use Flask instead of fastapi --- pyproject.toml | 2 +- src/serena/dashboard.py | 49 +++++++++++++++++++------- src/serena/process_isolated_agent.py | 25 +++++--------- uv.lock | 51 +++++++++++++++++++++++----- 4 files changed, 87 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c7bc40a4..9740f792 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "overrides>=7.7.0,<8", "python-dotenv>=1.0.0, <2", "mcp>=1.5.0", - "fastapi>=0.115.12", + "flask>=3.0.0", "sensai-utils>=1.4.0", "pydantic>=2.10.6", "types-pyyaml>=6.0.12.20241230", diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 22e34956..fec3cdaf 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -4,9 +4,7 @@ import socket import threading from collections.abc import Callable -import uvicorn -from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles +from flask import Flask, request, send_from_directory from pydantic import BaseModel from sensai.util import logging @@ -74,7 +72,7 @@ class SerenaDashboardAPI: self._memory_log_handler = memory_log_handler self._tool_names = tool_names self._shutdown_callback = shutdown_callback - self._app = FastAPI(title="Serena Dashboard") + self._app = Flask(__name__) self._setup_routes() @property @@ -82,21 +80,46 @@ class SerenaDashboardAPI: return self._memory_log_handler def _setup_routes(self) -> None: - self._app.mount("/dashboard", StaticFiles(directory=SERENA_DASHBOARD_DIR), name="dashboard") + # Static files + @self._app.route("/dashboard/") + def serve_dashboard(filename): + return send_from_directory(SERENA_DASHBOARD_DIR, filename) - self._app.add_api_route("/get_log_messages", self._get_log_messages, methods=["POST"], response_model=ResponseLog) - self._app.add_api_route("/get_tool_names", self._get_tool_names, methods=["GET"], response_model=ResponseToolNames) - self._app.add_api_route("/shutdown", self._shutdown, methods=["PUT"]) + @self._app.route("/dashboard/") + def serve_dashboard_index(): + return send_from_directory(SERENA_DASHBOARD_DIR, "index.html") - async def _get_log_messages(self, request: RequestLog) -> ResponseLog: + # API routes + @self._app.route("/get_log_messages", methods=["POST"]) + def get_log_messages(): + request_data = request.get_json() + if not request_data: + request_log = RequestLog() + else: + request_log = RequestLog.model_validate(request_data) + + result = self._get_log_messages(request_log) + return result.model_dump() + + @self._app.route("/get_tool_names", methods=["GET"]) + def get_tool_names(): + result = self._get_tool_names() + return result.model_dump() + + @self._app.route("/shutdown", methods=["PUT"]) + def shutdown(): + self._shutdown() + return {"status": "shutting down"} + + def _get_log_messages(self, request_log: RequestLog) -> ResponseLog: all_messages = self._memory_log_handler.get_log_messages() - requested_messages = all_messages[request.start_idx :] if request.start_idx <= len(all_messages) else [] + requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else [] return ResponseLog(messages=requested_messages, max_idx=len(all_messages) - 1) - async def _get_tool_names(self) -> ResponseToolNames: + def _get_tool_names(self) -> ResponseToolNames: return ResponseToolNames(tool_names=self._tool_names) - async def _shutdown(self) -> None: + def _shutdown(self) -> None: log.info("Shutting down Serena") if self._shutdown_callback: self._shutdown_callback() @@ -125,7 +148,7 @@ class SerenaDashboardAPI: """ Runs the dashboard on the given host and port and returns the port number. """ - uvicorn.run(self._app, host=host, port=port, workers=1, log_config=None, log_level="critical") + self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) return port def run_in_thread(self) -> tuple[threading.Thread, int]: diff --git a/src/serena/process_isolated_agent.py b/src/serena/process_isolated_agent.py index 48d5dc8c..b606c942 100644 --- a/src/serena/process_isolated_agent.py +++ b/src/serena/process_isolated_agent.py @@ -3,6 +3,7 @@ import contextlib import logging import multiprocessing import os +import threading import traceback import webbrowser from enum import StrEnum @@ -12,7 +13,6 @@ from multiprocessing.sharedctypes import Synchronized from multiprocessing.synchronize import Event as EventClass from typing import Any, Literal, Self -import uvicorn from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata from serena.agent import SerenaAgent, SerenaConfig, SerenaConfigBase, Tool, ToolInterface, ToolRegistry @@ -79,30 +79,21 @@ def _dashboard_worker( port = api._find_first_free_port(0x5EDA) port_value.value = port - # Start server first, then log processing - config = uvicorn.Config( - app=api._app, - host="0.0.0.0", - port=port, - workers=1, - log_config=None, - log_level="critical", - ) - server = uvicorn.Server(config) + # Start Flask server in a thread + def run_flask_server(): + api._app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True) + + server_thread = threading.Thread(target=run_flask_server, daemon=True) + server_thread.start() - # Serve and await shutdown signal - server_task = asyncio.create_task(server.serve()) shutdown_task = asyncio.create_task(_monitor_shutdown()) logging_loop_task = asyncio.create_task(_process_logs(api)) dashboard_ready_event.set() done, pending = await asyncio.wait( - [server_task, shutdown_task, logging_loop_task], + [shutdown_task, logging_loop_task], return_when=asyncio.FIRST_COMPLETED, ) - if shutdown_task in done: - server.should_exit = True - await server_task # Cancel remaining tasks for task in pending: diff --git a/uv.lock b/uv.lock index 05834d9c..c7318117 100644 --- a/uv.lock +++ b/uv.lock @@ -101,6 +101,15 @@ jupyter = [ { name = "tokenize-rt" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -210,17 +219,20 @@ wheels = [ ] [[package]] -name = "fastapi" -version = "0.115.12" +name = "flask" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440 } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 }, + { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 }, ] [[package]] @@ -394,6 +406,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -959,7 +980,7 @@ source = { editable = "." } dependencies = [ { name = "docstring-parser" }, { name = "dotenv" }, - { name = "fastapi" }, + { name = "flask" }, { name = "jinja2" }, { name = "joblib" }, { name = "mcp" }, @@ -1007,7 +1028,7 @@ requires-dist = [ { name = "black", extras = ["jupyter"], marker = "extra == 'dev'", specifier = ">=23.7.0" }, { name = "docstring-parser", specifier = ">=0.16" }, { name = "dotenv", specifier = ">=0.9.9" }, - { name = "fastapi", specifier = ">=0.115.12" }, + { name = "flask", specifier = ">=3.0.0" }, { name = "google-genai", marker = "extra == 'google'", specifier = ">=1.8.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", marker = "extra == 'dev'" }, @@ -1299,3 +1320,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828 }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, ] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +] From e32caeb9d6b9297cfaf6a4de1574434535d8e97a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 21:31:30 +0200 Subject: [PATCH 23/30] Disable werkzeug logs --- src/serena/dashboard.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index fec3cdaf..47bbe69b 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -12,6 +12,9 @@ from serena.constants import SERENA_DASHBOARD_DIR log = logging.getLogger(__name__) +# disable Werkzeug's logging to avoid cluttering the output +logging.getLogger("werkzeug").setLevel(logging.WARNING) + class MemoryLogHandler(logging.Handler): def __init__(self, level: int = logging.NOTSET) -> None: From 5ae127722ff393b27b34d85fc7bbaa6756aaefd5 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 21:46:26 +0200 Subject: [PATCH 24/30] Prevent Flask from printing a startup message --- src/serena/dashboard.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 47bbe69b..ee59aba8 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -151,6 +151,11 @@ class SerenaDashboardAPI: """ Runs the dashboard on the given host and port and returns the port number. """ + # patch flask.cli.show_server to avoid printing the server info + from flask import cli + + cli.show_server_banner = lambda *args, **kwargs: None + self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) return port From 772bc56fc37680d053d445b0f78a588c0b2502db Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 21 Jun 2025 21:52:00 +0200 Subject: [PATCH 25/30] Update references to make_tool function in tests --- src/serena/mcp.py | 8 ++++---- test/serena/test_make_tool_process_isolation.py | 4 +++- test/serena/test_mcp.py | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 2764f80d..856ad7b1 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -79,7 +79,7 @@ class SerenaMCPFactory: self.project = project @staticmethod - def _make_mcp_tool(tool: ToolInterface) -> MCPTool: + def make_mcp_tool(tool: ToolInterface) -> MCPTool: func_name = tool.get_name() func_doc = tool.get_apply_docstring() or "" func_arg_metadata = tool.get_apply_fn_metadata() @@ -132,7 +132,7 @@ class SerenaMCPFactory: if mcp is not None: mcp._tool_manager._tools = {} for tool in self._iter_tools(): - mcp_tool = self._make_mcp_tool(tool) + mcp_tool = self.make_mcp_tool(tool) mcp._tool_manager._tools[tool.get_name()] = mcp_tool @abstractmethod @@ -262,7 +262,7 @@ class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory): return tool_names_included_in_this_session @staticmethod - def _make_mcp_tool(tool: ToolInterface) -> MCPTool: + def make_mcp_tool(tool: ToolInterface) -> MCPTool: func_name = tool.get_name() func_doc = tool.get_apply_docstring() or "" func_arg_metadata = tool.get_apply_fn_metadata() @@ -315,7 +315,7 @@ class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory): if mcp is not None: mcp._tool_manager._tools = {} for tool in self._iter_tools(): - mcp_tool = self._make_mcp_tool(tool) + mcp_tool = self.make_mcp_tool(tool) mcp._tool_manager._tools[tool.get_name()] = mcp_tool def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: diff --git a/test/serena/test_make_tool_process_isolation.py b/test/serena/test_make_tool_process_isolation.py index fb13f866..eb9b3f06 100644 --- a/test/serena/test_make_tool_process_isolation.py +++ b/test/serena/test_make_tool_process_isolation.py @@ -4,10 +4,12 @@ import pytest from mcp.server.fastmcp.tools.base import Tool as MCPTool from serena.agent import SerenaAgent, ToolRegistry -from serena.mcp import make_tool +from serena.mcp import SerenaMCPFactory from serena.process_isolated_agent import ProcessIsolatedSerenaAgent, ProcessIsolatedTool from test.serena.test_serena_agent import SerenaConfigForTests +make_tool = SerenaMCPFactory.make_mcp_tool + @pytest.fixture def in_memory_config(): diff --git a/test/serena/test_mcp.py b/test/serena/test_mcp.py index eed27a7f..8ffb2c21 100644 --- a/test/serena/test_mcp.py +++ b/test/serena/test_mcp.py @@ -4,7 +4,9 @@ import pytest from mcp.server.fastmcp.tools.base import Tool as MCPTool from serena.agent import Tool, ToolRegistry -from serena.mcp import make_tool +from serena.mcp import SerenaMCPFactory + +make_tool = SerenaMCPFactory.make_mcp_tool class BaseMockTool(Tool): From ceae28d286b5d647d4e07b9e31407534c86e71eb Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 22 Jun 2025 01:09:40 +0200 Subject: [PATCH 26/30] Fix hanging of tool executions that use shell execution #212 Affected ExecuteShellCommandTool and GetCurrentConfigTool (because of git status). Solution: Adjust parameters for subprocess creation --- src/serena/__init__.py | 12 +++++++----- src/serena/util/git.py | 21 +++++++++++++++++++++ src/serena/util/shell.py | 20 +++++++++++++++++++- 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 src/serena/util/git.py diff --git a/src/serena/__init__.py b/src/serena/__init__.py index 1a6b2383..9ee9fb1f 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -1,17 +1,19 @@ __version__ = "2025-05-21" +import logging + +log = logging.getLogger(__name__) + def serena_version() -> str: """ :return: the version of the package, including git status if available. """ + from serena.util.git import get_git_status + version = __version__ try: - from sensai.util.git import git_status - from sensai.util.logging import LoggingDisabledContext - - with LoggingDisabledContext(): - git_status = git_status() + git_status = get_git_status() version += f"-{git_status.commit[:8]}" if not git_status.is_clean: version += "-dirty" diff --git a/src/serena/util/git.py b/src/serena/util/git.py new file mode 100644 index 00000000..b46d17e2 --- /dev/null +++ b/src/serena/util/git.py @@ -0,0 +1,21 @@ +import logging + +from sensai.util.git import GitStatus + +from .shell import subprocess_check_output + +log = logging.getLogger(__name__) + + +def get_git_status() -> GitStatus | None: + try: + commit_hash = subprocess_check_output(["git", "rev-parse", "HEAD"]) + unstaged = bool(subprocess_check_output(["git", "diff", "--name-only"])) + staged = bool(subprocess_check_output(["git", "diff", "--staged", "--name-only"])) + untracked = bool(subprocess_check_output(["git", "ls-files", "--others", "--exclude-standard"])) + return GitStatus( + commit=commit_hash, has_unstaged_changes=unstaged, has_staged_uncommitted_changes=staged, has_untracked_files=untracked + ) + except Exception as e: + log.error("Error determining Git status", exc_info=e) + return None diff --git a/src/serena/util/shell.py b/src/serena/util/shell.py index d940384d..faef516f 100644 --- a/src/serena/util/shell.py +++ b/src/serena/util/shell.py @@ -24,11 +24,14 @@ def execute_shell_command(command: str, cwd: str | None = None, capture_stderr: if cwd is None: cwd = os.getcwd() + is_windows = platform.system() == "Windows" process = subprocess.Popen( command, - shell=platform.system() != "Windows", + shell=not is_windows, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE if capture_stderr else None, + creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0, text=True, encoding="utf-8", errors="replace", @@ -37,3 +40,18 @@ def execute_shell_command(command: str, cwd: str | None = None, capture_stderr: stdout, stderr = process.communicate() return ShellCommandResult(stdout=stdout, stderr=stderr, return_code=process.returncode, cwd=cwd) + + +def subprocess_check_output(args: list[str], encoding: str = "utf-8", strip: bool = True, timeout: float | None = None) -> str: + kwargs = { + "stdin": subprocess.DEVNULL, + "stderr": subprocess.PIPE, + "timeout": timeout, + "env": os.environ.copy(), + } + if platform.system() == "Windows": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + output = subprocess.check_output(args, **kwargs).decode(encoding) + if strip: + output = output.strip() + return output From 34d844506f2d6717a775ef1da8c90156e9ed02a7 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 22 Jun 2025 10:40:26 +0200 Subject: [PATCH 27/30] Update change log --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b8f786..51a8fc82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* **Reduce the use of asyncio to a minimum**, improving stability and reducing the need for workarounds + * Switch to newly developed fully synchronous LSP library `solidlsp` (derived from `multilspy`) + * Switch from fastapi (which uses asyncio) to Flask in the Serena dashboard + * The MCP server is the only asynchronous component now, which resolves cross-component loop contamination, + such that process isolation is no longer required. + Neither are non-graceful shutdowns on Windows. + +Fixes: +* Fix `ExecuteShellCommandTool` and `GetCurrentConfigTool` hanging on Windows +* Fix project activation by name via `--project` not working (was broken in previous release) + # 2025-06-20 * **Overhaul and major improvement of editing tools!** From 8958a2f6417eb47030e1d03459384dbf50eb2c34 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 22 Jun 2025 11:21:12 +0200 Subject: [PATCH 28/30] Add comprehensive solid-lsp architecture memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the solid-lsp implementation as a deadlock-resistant replacement for multilspy, designed to solve asyncio contamination issues in MCP environments. Key points covered: - Root cause analysis of multilspy deadlocks - Solid-lsp architectural improvements - Process isolation no longer needed - Language server compatibility - Migration strategy 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .serena/memories/solid-lsp-architecture.md | 161 +++++++++++++++++++++ src/serena/constants.py | 4 + 2 files changed, 165 insertions(+) create mode 100644 .serena/memories/solid-lsp-architecture.md diff --git a/.serena/memories/solid-lsp-architecture.md b/.serena/memories/solid-lsp-architecture.md new file mode 100644 index 00000000..4a334cf2 --- /dev/null +++ b/.serena/memories/solid-lsp-architecture.md @@ -0,0 +1,161 @@ +# Solid-LSP: Deadlock-Free Language Server Architecture + +## Overview + +Solid-LSP (`src/solidlsp/`) is a **simplified, deadlock-resistant** reimplementation of the language server communication layer, designed to replace the problematic `multilspy` architecture that suffered from asyncio contamination issues when used with MCP servers. + +## The Problem Solid-LSP Solves + +### Root Cause: Asyncio Context Contamination +The original `multilspy` implementation created **asyncio deadlocks** in MCP environments due to: + +1. **Dual Event Loop Interference**: MCP server runs its own asyncio event loop, while SerenaAgent creates separate asyncio loops for language servers and dashboard +2. **Coroutine Leakage**: When MCP server calls SerenaAgent tools, coroutines created by `multilspy.LanguageServer.request_full_symbol_tree()` were leaked into the MCP server's asyncio context but never awaited +3. **Resource Exhaustion**: Accumulated unawaited coroutines in the MCP server's event loop eventually caused blocking and apparent "hangs" +4. **MCP-Specific Issue**: Only occurred when using MCP clients, not in direct script execution + +### Evidence from Logs +``` +INFO mcp.server.lowlevel.server:_handle_message:524 - Warning: RuntimeWarning: coroutine 'LanguageServer.request_full_symbol_tree' was never awaited +``` + +## Solid-LSP Architecture + +### Core Design Principles + +1. **Simplified Protocol Handling**: Eliminates the complex `lsp_protocol_handler` layer from `multilspy` +2. **Direct Async Management**: More explicit control over async operations without complex threading abstractions +3. **Single Process Operation**: Designed to work directly in the MCP server process without requiring process isolation +4. **Clean Asyncio Boundaries**: Prevents coroutine leakage between async contexts + +### Key Architectural Differences from multilspy + +#### 1. Simplified Handler Architecture +- **multilspy**: Complex `LanguageServerHandler` with extensive async task management and event loops +- **solid-lsp**: Streamlined `SolidLanguageServerHandler` with direct process management + +#### 2. Protocol Simplification +- **No separate protocol handler package**: Direct LSP communication without abstraction layers +- **Reduced complexity**: Fewer components and dependencies +- **Same language server support**: Identical `language_servers/` implementations for all supported languages + +#### 3. Enhanced Process Control +**solid-lsp** provides additional methods for finer process control: +- `_start_server_process()`: Direct process control +- `_start_server()`: Server initialization +- `_server_context` attribute: Enhanced server state management + +## Process Isolation: No Longer Required + +### Key Benefit: Single Process Operation + +With solid-lsp, **process isolation is no longer needed** and is **disabled by default**: + +```python +# src/serena/mcp.py:581-585 +if USE_SOLID_LSP: + mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) +else: + # using multilspy requires process isolation to prevent asyncio contamination + mcp_factory = SerenaMCPFactoryWithProcessIsolation(context=context, project=project_file) +``` + +### Benefits of Single Process Operation +1. **Lower Resource Usage**: No separate processes for SerenaAgent and language servers +2. **Reduced Latency**: Direct method calls instead of inter-process communication +3. **Simplified Architecture**: No IPC overhead or process management complexity +4. **Better Performance**: Eliminated serialization/deserialization overhead +5. **Easier Debugging**: All components in same process, simplified stack traces + +### Why multilspy Required Process Isolation +- **Asyncio Contamination**: multilspy's async patterns leaked coroutines into MCP server context +- **Mandatory Isolation**: Process boundaries were the only way to prevent deadlocks +- **Performance Penalty**: IPC overhead was necessary for stability + +### How solid-lsp Enables Single Process +- **Clean Async Patterns**: No coroutine leakage between MCP server and language server contexts +- **Direct Integration**: Can safely run in same process as MCP server +- **Stable Operation**: No more timeout issues or hanging operations + +## Supported Language Servers + +Solid-LSP maintains compatibility with all language servers from `multilspy`: + +### Primary Languages +- **Python**: Pyright Language Server (`src/solidlsp/language_servers/pyright_language_server/`) +- **Java**: Eclipse JDTLS (`src/solidlsp/language_servers/eclipse_jdtls/`) +- **TypeScript/JavaScript**: TypeScript Language Server (`src/solidlsp/language_servers/typescript_language_server/`) + +### Additional Languages +- **C#**: OmniSharp (`src/solidlsp/language_servers/omnisharp/`) +- **Rust**: Rust-Analyzer (`src/solidlsp/language_servers/rust_analyzer/`) +- **Go**: Gopls (`src/solidlsp/language_servers/gopls/`) +- **Ruby**: Solargraph (`src/solidlsp/language_servers/solargraph/`) +- **C++**: Clangd (`src/solidlsp/language_servers/clangd_language_server/`) +- **Dart**: Dart Language Server (`src/solidlsp/language_servers/dart_language_server/`) +- **PHP**: Intelephense (`src/solidlsp/language_servers/intelephense/`) +- **Kotlin**: Kotlin Language Server (`src/solidlsp/language_servers/kotlin_language_server/`) + +## Implementation Details + +### Core Classes + +#### `SolidLanguageServer` (`src/solidlsp/ls.py`) +- **1,634 lines**: Main language server interface +- **Enhanced methods**: Same semantic capabilities as `multilspy.LanguageServer` but with improved async handling +- **Lifecycle control**: `start()`, `stop()`, `is_running()`, `language_server()` property (similar to multilspy's `SyncLanguageServer`) + +#### `SolidLanguageServerHandler` (`src/solidlsp/ls_handler.py`) +- **512 lines**: Simplified LSP protocol handler +- **Direct process management**: More straightforward than `multilspy`'s complex async task orchestration +- **Improved cleanup**: Better resource management and process termination + +#### `SolidLspRequest` (`src/solidlsp/lsp_request.py`) +- **Simplified request handling**: Direct LSP request/response without complex protocol abstractions + +### Configuration Integration + +Solid-LSP is controlled by the `USE_SOLID_LSP` constant in `src/serena/constants.py`: + +```python +USE_SOLID_LSP = True # Default: enabled +``` + +This allows: +- **Default Operation**: solid-lsp enabled by default for stability +- **Fallback Capability**: Can switch back to multilspy + process isolation if needed +- **A/B Testing**: Easy comparison between implementations + +## Deadlock Prevention + +### How Solid-LSP Prevents Deadlocks + +1. **Simplified Async Patterns**: Reduces complexity that led to coroutine leakage +2. **Clean Boundaries**: Proper async context management prevents MCP server contamination +3. **Direct Operations**: Eliminates abstraction layers where async context could be corrupted +4. **Single Process Safety**: Designed to work safely within MCP server process + +### Verification + +The solid-lsp approach provides: +- **No more timeout issues**: Clean async boundaries prevent MCP server contamination +- **Stable MCP operation**: Language server calls no longer hang after first timeout +- **Resource efficiency**: Lower memory usage without separate processes +- **Reliable semantic tools**: `find_symbol` and other tools work consistently +- **Better Performance**: Direct method calls instead of IPC + +## Migration Strategy + +### Current Status +- **Default**: Solid-LSP is the default implementation (`USE_SOLID_LSP = True`) +- **Stable**: Resolves known deadlock issues in MCP environments +- **Compatible**: Supports all existing language servers and semantic operations +- **Performant**: Single process operation with lower overhead + +### Rollback Path +If issues arise, can fallback to multilspy with process isolation: +1. **Disable solid-lsp**: Set `USE_SOLID_LSP = False` +2. **Automatic fallback**: System automatically uses multilspy + process isolation +3. **Performance trade-off**: Higher resource usage but guaranteed isolation + +Solid-LSP represents an **architectural evolution** that eliminates the need for process isolation while maintaining all semantic capabilities and providing **better performance and stability** for MCP server deployments. \ No newline at end of file diff --git a/src/serena/constants.py b/src/serena/constants.py index 86c1b31f..8b9a4dde 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -20,3 +20,7 @@ PROJECT_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.template.y SELENA_CONFIG_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "serena_config.template.yml") USE_SOLID_LSP = True +"""Whether to use Solid LSP instead of multilspy for language server operations. +Multilspy uses asyncio, which creates many problems like deadlocks and requires full process +isolation (and even then some issues remain). +""" \ No newline at end of file From 38b138bb4700ae61d329cfbaaba0a0ad87a549de Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 22 Jun 2025 11:49:47 +0200 Subject: [PATCH 29/30] Fix mypy issues --- src/serena/__init__.py | 7 ++++--- src/serena/dashboard.py | 13 +++++++------ src/serena/mcp.py | 18 +++++++++++++----- src/serena/process_isolated_agent.py | 6 +++--- src/serena/util/shell.py | 2 +- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/serena/__init__.py b/src/serena/__init__.py index 2353c4e5..1643d835 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -14,9 +14,10 @@ def serena_version() -> str: version = __version__ try: git_status = get_git_status() - version += f"-{git_status.commit[:8]}" - if not git_status.is_clean: - version += "-dirty" + if git_status is not None: + version += f"-{git_status.commit[:8]}" + if not git_status.is_clean: + version += "-dirty" except: pass return version diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index ee59aba8..eb86e74a 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -3,8 +3,9 @@ import queue import socket import threading from collections.abc import Callable +from typing import Any -from flask import Flask, request, send_from_directory +from flask import Flask, Response, request, send_from_directory from pydantic import BaseModel from sensai.util import logging @@ -85,16 +86,16 @@ class SerenaDashboardAPI: def _setup_routes(self) -> None: # Static files @self._app.route("/dashboard/") - def serve_dashboard(filename): + def serve_dashboard(filename: str) -> Response: return send_from_directory(SERENA_DASHBOARD_DIR, filename) @self._app.route("/dashboard/") - def serve_dashboard_index(): + def serve_dashboard_index() -> Response: return send_from_directory(SERENA_DASHBOARD_DIR, "index.html") # API routes @self._app.route("/get_log_messages", methods=["POST"]) - def get_log_messages(): + def get_log_messages() -> dict[str, Any]: request_data = request.get_json() if not request_data: request_log = RequestLog() @@ -105,12 +106,12 @@ class SerenaDashboardAPI: return result.model_dump() @self._app.route("/get_tool_names", methods=["GET"]) - def get_tool_names(): + def get_tool_names() -> dict[str, Any]: result = self._get_tool_names() return result.model_dump() @self._app.route("/shutdown", methods=["PUT"]) - def shutdown(): + def shutdown() -> dict[str, str]: self._shutdown() return {"status": "shutting down"} diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 856ad7b1..28150bab 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -184,11 +184,11 @@ class SerenaMCPFactory: # retain only FASTMCP_ prefix for already set environment variables. Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") - mcp_settings = Settings(lifespan=self.server_lifespan, host=host, port=port) + mcp_settings: Settings = Settings(lifespan=self.server_lifespan, host=host, port=port) mcp = FastMCP(**mcp_settings.model_dump()) return mcp - @asynccontextmanager + @asynccontextmanager # type: ignore @abstractmethod async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]: """Manage server startup and shutdown lifecycle.""" @@ -213,6 +213,7 @@ class SerenaMCPFactorySingleProcess(SerenaMCPFactory): self.agent = SerenaAgent(project=self.project, serena_config=serena_config, context=self.context, modes=modes) def _iter_tools(self) -> Iterator[ToolInterface]: + assert self.agent is not None yield from self.agent.get_exposed_tool_instances() @asynccontextmanager @@ -236,12 +237,12 @@ class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory): """ super().__init__(context=context, project=project) - self.active_tool_names: list[str] | None = None + self.active_tool_names: set[str] | None = None self.serena_agent_process: ProcessIsolatedSerenaAgent | None = None self.serena_dashboard_process: ProcessIsolatedDashboard | None = None @staticmethod - def _determine_active_tool_names(context: SerenaAgentContext, project: Project) -> set[str]: + def _determine_active_tool_names(context: SerenaAgentContext, project: Project | None) -> set[str]: """ Determine the names of tools that should be included in this session based on the context. """ @@ -306,6 +307,8 @@ class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory): ) def _iter_tools(self) -> Iterator[ToolInterface]: + assert self.active_tool_names is not None + assert self.serena_agent_process is not None for tool_name in self.active_tool_names: yield ProcessIsolatedTool(process_agent=self.serena_agent_process, tool_name=tool_name) @@ -319,12 +322,14 @@ class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory): mcp._tool_manager._tools[tool.get_name()] = mcp_tool def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: - self.project_instance = serena_config.get_project(self.project) + if self.project is not None: + self.project_instance = serena_config.get_project(self.project) self.serena_agent_process = ProcessIsolatedSerenaAgent( project=self.project, serena_config=serena_config, modes=modes, context=self.context ) self.active_tool_names = self._determine_active_tool_names(self.context, self.project_instance) if serena_config.web_dashboard: + assert self.active_tool_names is not None self.serena_dashboard_process = ProcessIsolatedDashboard(tool_names=sorted(self.active_tool_names)) def create_mcp_server( @@ -399,8 +404,10 @@ class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory): if self.serena_dashboard_process is not None: log.info("Starting dashboard process") + assert self.serena_dashboard_process is not None self.serena_dashboard_process.start() log.info("Starting serena agent process") + assert self.serena_agent_process is not None self.serena_agent_process.start() self._set_mcp_tools(mcp_server) @@ -578,6 +585,7 @@ def start_mcp_server( # This is for backward compatibility with the old CLI, should be removed in the future! project_file = project_file_arg if project_file_arg is not None else project + mcp_factory: SerenaMCPFactory if USE_SOLID_LSP: mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) else: diff --git a/src/serena/process_isolated_agent.py b/src/serena/process_isolated_agent.py index b606c942..081dcc65 100644 --- a/src/serena/process_isolated_agent.py +++ b/src/serena/process_isolated_agent.py @@ -80,7 +80,7 @@ def _dashboard_worker( port_value.value = port # Start Flask server in a thread - def run_flask_server(): + def run_flask_server() -> None: api._app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True) server_thread = threading.Thread(target=run_flask_server, daemon=True) @@ -414,10 +414,10 @@ class ProcessIsolatedSerenaAgent: # Create communication pipe parent_conn, child_conn = multiprocessing.Pipe() - self.conn = parent_conn + self.conn = parent_conn # type: ignore # Create and start worker process, passing along the dashboard's queue if available - worker = SerenaAgentWorker(child_conn) + worker = SerenaAgentWorker(child_conn) # type: ignore self.process = multiprocessing.Process(target=worker.run, args=[_global_log_queue]) self.process.start() diff --git a/src/serena/util/shell.py b/src/serena/util/shell.py index faef516f..05080e0e 100644 --- a/src/serena/util/shell.py +++ b/src/serena/util/shell.py @@ -51,7 +51,7 @@ def subprocess_check_output(args: list[str], encoding: str = "utf-8", strip: boo } if platform.system() == "Windows": kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW - output = subprocess.check_output(args, **kwargs).decode(encoding) + output = subprocess.check_output(args, **kwargs).decode(encoding) # type: ignore if strip: output = output.strip() return output From d680c83f767e4da24fc270df1e7cf1bac481261b Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 22 Jun 2025 11:58:00 +0200 Subject: [PATCH 30/30] Fixing more minor typing issues --- src/serena/constants.py | 2 +- src/serena/mcp.py | 3 ++- src/serena/process_isolated_agent.py | 4 ++-- src/serena/util/shell.py | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/serena/constants.py b/src/serena/constants.py index 8b9a4dde..6a277598 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -23,4 +23,4 @@ USE_SOLID_LSP = True """Whether to use Solid LSP instead of multilspy for language server operations. Multilspy uses asyncio, which creates many problems like deadlocks and requires full process isolation (and even then some issues remain). -""" \ No newline at end of file +""" diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 28150bab..e2a0bc95 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -188,10 +188,11 @@ class SerenaMCPFactory: mcp = FastMCP(**mcp_settings.model_dump()) return mcp - @asynccontextmanager # type: ignore + @asynccontextmanager @abstractmethod async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]: """Manage server startup and shutdown lifecycle.""" + yield None # ensures MyPy understands we yield None class SerenaMCPFactorySingleProcess(SerenaMCPFactory): diff --git a/src/serena/process_isolated_agent.py b/src/serena/process_isolated_agent.py index 081dcc65..90699b03 100644 --- a/src/serena/process_isolated_agent.py +++ b/src/serena/process_isolated_agent.py @@ -414,10 +414,10 @@ class ProcessIsolatedSerenaAgent: # Create communication pipe parent_conn, child_conn = multiprocessing.Pipe() - self.conn = parent_conn # type: ignore + self.conn = parent_conn # Create and start worker process, passing along the dashboard's queue if available - worker = SerenaAgentWorker(child_conn) # type: ignore + worker = SerenaAgentWorker(child_conn) self.process = multiprocessing.Process(target=worker.run, args=[_global_log_queue]) self.process.start() diff --git a/src/serena/util/shell.py b/src/serena/util/shell.py index 05080e0e..c899b212 100644 --- a/src/serena/util/shell.py +++ b/src/serena/util/shell.py @@ -31,7 +31,7 @@ def execute_shell_command(command: str, cwd: str | None = None, capture_stderr: stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE if capture_stderr else None, - creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0, + creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0, # type: ignore text=True, encoding="utf-8", errors="replace", @@ -50,7 +50,7 @@ def subprocess_check_output(args: list[str], encoding: str = "utf-8", strip: boo "env": os.environ.copy(), } if platform.system() == "Windows": - kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW # type: ignore output = subprocess.check_output(args, **kwargs).decode(encoding) # type: ignore if strip: output = output.strip()