From 283140e7aea304788e37effb23c7c5d2778f74c4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 4 Sep 2026 15:19:22 +0200 Subject: [PATCH] Refactor external language server registration * Apply object-oriented design * Establish common interface via protocol LanguageServerIdLike * Move all registration concerns to LanguageServerRegistry --- docs/01-about/020_programming-languages.md | 8 +- .../030_external_language_server_adapters.md | 55 --- docs/02-usage/040_workflow.md | 2 +- docs/03-special-guides/000_intro.md | 3 +- .../external_language_server_registration.md | 55 +++ src/serena/agent.py | 14 +- src/serena/cli.py | 12 +- src/serena/config/serena_config.py | 36 +- src/serena/dashboard.py | 30 +- src/serena/ls_manager.py | 54 +-- src/serena/project.py | 14 +- .../language_server_adapter_discovery.py | 72 ---- .../angular_language_server.py | 5 - .../language_servers/scala_language_server.py | 2 +- .../svelte_language_server.py | 6 - .../language_servers/vue_language_server.py | 11 - src/solidlsp/ls.py | 10 +- src/solidlsp/ls_config.py | 247 +++++------ src/solidlsp/ls_exceptions.py | 4 +- src/solidlsp/ls_process.py | 18 +- src/solidlsp/settings.py | 24 +- test/conftest.py | 6 +- test/serena/test_dashboard.py | 172 +------- test/solidlsp/bsl/test_bsl_basic.py | 8 +- test/solidlsp/r/test_r_basic.py | 5 - test/solidlsp/test_ls_registry.py | 406 ------------------ 26 files changed, 301 insertions(+), 978 deletions(-) delete mode 100644 docs/01-about/030_external_language_server_adapters.md create mode 100644 docs/03-special-guides/external_language_server_registration.md delete mode 100644 src/solidlsp/language_server_adapter_discovery.py delete mode 100644 test/solidlsp/test_ls_registry.py diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 82b3ffee..ede55fab 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -190,11 +190,9 @@ Some languages require additional installations or setup steps, as noted. * **Zig** (requires installation of ZLS - Zig Language Server) -Support for further languages can be added either as a built-in Serena language-server implementation or as an external Python adapter. -External adapters are supplied by installed packages and registered through the -[external language-server adapter](030_external_language_server_adapters) entry-point mechanism. -The external mechanism does not participate in automatic language detection; add the registered ID explicitly to `project.yml`. -For built-in integrations, see Serena's [guide for adding new language support](https://github.com/oraios/serena/blob/main/.serena/memories/adding_new_language_support_guide.md). +Additional language servers can easily be supported by providing an adapter implementation; +see our [contribution guide](https://github.com/oraios/serena/blob/main/CONTRIBUTING.md). +If you need to support a custom language server which is not yet publicly available, you have the option to [register an external language server](external-ls-registration). ## The Serena JetBrains Plugin diff --git a/docs/01-about/030_external_language_server_adapters.md b/docs/01-about/030_external_language_server_adapters.md deleted file mode 100644 index 66e08a62..00000000 --- a/docs/01-about/030_external_language_server_adapters.md +++ /dev/null @@ -1,55 +0,0 @@ -# External Language Server Adapters - -Serena can load language-server integrations provided by installed Python packages. This lets a project use an external language server without adding its adapter to the Serena core. - -## How It Works - -An adapter package exposes a Python entry point in the `serena.language_servers` group: - -```toml -[project.entry-points."serena.language_servers"] -example = "example_serena_adapter:register" -``` - -Serena discovers these entry points with Python's `importlib.metadata.entry_points()` at the regular `ProjectConfig.load()` lifecycle boundary. The loaded registration function provides the actual Python `SolidLanguageServer` implementation and calls the existing registry API: - -```python -from solidlsp.ls_config import FilenameMatcher, register_ls - - -def register() -> None: - register_ls( - id="example", - matcher=FilenameMatcher(".example"), - implementation=ExampleLanguageServer, - ) -``` - -Several external adapters can be installed and registered at the same time. Their IDs can then be used in a project's `.serena/project.yml`: - -```yaml -language_servers: - - example -``` - -The registered implementation is started and managed by Serena. Serena launches the external server using the adapter's process configuration; it does not search for already-running LSP processes. - -## Trust and Security - -Only entry points exposed by installed Python packages are considered. Project configuration does not import Python code, accept arbitrary module paths, or perform filesystem-based plugin discovery. Serena does not install plugins automatically or download adapters from a marketplace. - -An installed adapter contains executable Python code and therefore has the same trust implications as any other installed Python package. Install adapters only from sources you trust. The project can reference a registered ID, but it cannot cause an arbitrary Python module to be loaded through `project.yml`. - -## Errors and IDs - -The first discovery is serialized across concurrent project loads and completes once entry-point metadata has been read successfully. A failure while reading the installed entry-point metadata is reported and leaves discovery retryable. If one adapter fails while loading or registering, Serena reports the entry-point name and distribution when available, rolls back that adapter's registrations, and continues loading other adapters. - -A failed adapter ID is not available afterward. If a project references it, normal language-server configuration validation reports an unknown ID. Registered IDs must be unique and cannot replace a built-in Serena language-server ID. - -Adapter IDs must be non-empty, trimmed, and lowercase. Each external `SolidLanguageServer` implementation class can be registered under only one external ID. - -## Built-in and External Integrations - -Built-in Serena language servers are implemented and mapped inside the Serena distribution and use the existing built-in language-server IDs. An external adapter is implemented in its own Python package, registers its ID through the entry-point contract above, and supplies the integration required to launch and communicate with its language server. - -This mechanism does not change Serena's language auto-detection. External adapters are available when explicitly registered and referenced in project configuration. diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index 7f784b63..fc0202a5 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -47,7 +47,7 @@ The file allows you to configure ... * the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project) * the set of built-in language servers and explicitly registered external adapters for which servers are spawned (not relevant when using the JetBrains plugin). External adapter IDs become available after their Python package is installed; - see [External Language Server Adapters](../01-about/030_external_language_server_adapters). + see [External Language Server Adapters](../03-special-guides/external_language_server_registration.md). The [Dashboard](060_dashboard) can dynamically add or remove built-in language servers and installed, registered external adapters while Serena is running. It does not install adapter packages or offer unregistered adapter IDs. * the [language backend](per-project-language-backend) to use for this project (overriding the global setting) diff --git a/docs/03-special-guides/000_intro.md b/docs/03-special-guides/000_intro.md index d2d34716..f4492737 100644 --- a/docs/03-special-guides/000_intro.md +++ b/docs/03-special-guides/000_intro.md @@ -1,3 +1,4 @@ # Special Guides -This section contains special guides for certain topics that require more in-depth explanations. \ No newline at end of file +This section contains special guides for certain topics that require more in-depth explanations +or are too specific to be included in the main documentation. These guides are meant to provide additional context and instructions for users who need them. \ No newline at end of file diff --git a/docs/03-special-guides/external_language_server_registration.md b/docs/03-special-guides/external_language_server_registration.md new file mode 100644 index 00000000..6baa16cf --- /dev/null +++ b/docs/03-special-guides/external_language_server_registration.md @@ -0,0 +1,55 @@ +(external-ls-registration)= +# External Language Server Registration + +SolidLSP, Serena's language server framework, is designed to be extensible. +Sometimes, you may need to use Serena with a language server for which there can be no built-in adapter, +e.g. because the language server in question is a custom development which is not yet released. + +The general mechanism to make SolidLSP aware of an external language server is to use the +`LanguageServerRegistry` in order to register an `ExternalLanguageServerId`. + +You can either do this + * programmatically, in your own Python code, if you are launching Serena via a custom script + where this code can be executed before Serena itself is started, or + * via a Python package that exposes a `solidlsp.language_server_registration` entry point. + If the Python package is installed in the same environment as Serena, + the language server will be automatically registered when Serena starts. + +## Registering an External Language Server via a Python Package + +Expose a Python entry point in the `solidlsp.language_server_registration` group (`pyproject.toml`): + +```toml +[project.entry-points."solidlsp.language_server_registration"] +example = "myls:register_myls" +``` + +Corresponding contents of `myls/__init__.py`: + +```python +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import FilenameMatcher, LanguageServerRegistry, ExternalLanguageServerId + + +class MyLanguageServerImpl(SolidLanguageServer): + # implementation of the language server adapter + pass + + +def register_myls() -> None: + LanguageServerRegistry.get_instance().register( + ExternalLanguageServerId( + key="myls", + matcher=FilenameMatcher(".example"), + implementation=MyLanguageServerImpl + ) + ) +``` + +The registered key `myls` can subsequently be used in a project's configuration file (`project.yml`) +like any other language server: + +```yaml +language_servers: + - myls +``` diff --git a/src/serena/agent.py b/src/serena/agent.py index f5290b8f..bfdfceae 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -61,7 +61,7 @@ from serena.tools import ( from serena.util.gui import system_has_usable_display from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler -from solidlsp.ls_config import LanguageServerKey +from solidlsp.ls_config import LanguageServerIdLike from solidlsp.util import subprocess_util from solidlsp.util.subprocess_util import terminate_process_tree_with_kill_fallback @@ -1081,7 +1081,7 @@ class SerenaAgent: else: msg = f"The project with name '{proj.project_name}' at {proj.project_root} is activated.\n" if self._language_backend == LanguageBackend.LSP: - language_servers_str = ", ".join([ls.value for ls in proj.project_config.language_servers]) + language_servers_str = ", ".join([ls.get_key() for ls in proj.project_config.language_servers]) msg += f"Active language servers: {language_servers_str}.\n" msg += f"File encoding: {proj.project_config.encoding}.\n" @@ -1436,7 +1436,7 @@ class SerenaAgent: """ self.get_active_project_or_raise().create_language_server_manager() - def add_language_server(self, ls_id: LanguageServerKey) -> None: + def add_language_server(self, ls_id: LanguageServerIdLike) -> None: """ Adds a new language server to the active project, spawning the respective language server and updating the project configuration. The addition is scheduled via the agent's task executor and executed synchronously, i.e. the method returns @@ -1444,16 +1444,16 @@ class SerenaAgent: :param ls_id: the language server to add """ - self.execute_task(lambda: self.get_active_project_or_raise().add_language_server(ls_id), name=f"AddLanguage:{ls_id.value}") + self.execute_task(lambda: self.get_active_project_or_raise().add_language_server(ls_id), name=f"AddLanguage:{ls_id.get_key()}") - def remove_language_server(self, ls_id: LanguageServerKey) -> None: + def remove_language_server(self, ls_id: LanguageServerIdLike) -> None: """ Removes a language server from the active project, shutting down the respective server and updating the project configuration. The removal is scheduled via the agent's task executor and executed asynchronously. :param ls_id: the language to remove """ - self.issue_task(lambda: self.get_active_project_or_raise().remove_language_server(ls_id), name=f"RemoveLanguage:{ls_id.value}") + self.issue_task(lambda: self.get_active_project_or_raise().remove_language_server(ls_id), name=f"RemoveLanguage:{ls_id.get_key()}") def get_tool(self, tool_class: type[TTool]) -> TTool: return self._all_tools[tool_class] @@ -1498,7 +1498,7 @@ class SerenaAgent: tool_class = ToolRegistry().get_tool_class_by_name(tool_name) return self.get_tool(tool_class) - def get_active_language_server_ids(self) -> list[LanguageServerKey]: + def get_active_language_server_ids(self) -> list[LanguageServerIdLike]: ls_manager = self.get_language_server_manager() if ls_manager is None: return [] diff --git a/src/serena/cli.py b/src/serena/cli.py index 87bbafcf..c857d731 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -40,7 +40,7 @@ from serena.prompt_factory import SerenaPromptFactory from serena.tools import ActivateProjectTool from serena.util.cli_util import AutoRegisteringGroup from serena.util.logging import MemoryLogHandler -from solidlsp.ls_config import LanguageServerId, LanguageServerKey +from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike from solidlsp.ls_types import SymbolKind from solidlsp.util.subprocess_util import subprocess_kwargs @@ -722,7 +722,9 @@ class ProjectCommands(AutoRegisteringGroup): languages=languages if languages else None, interactive=True, ) - languages_str = ", ".join([lang.value for lang in generated_conf.language_servers]) if generated_conf.language_servers else "N/A" + languages_str = ( + ", ".join([lang.get_key() for lang in generated_conf.language_servers]) if generated_conf.language_servers else "N/A" + ) click.echo(f"Generated project with language servers {{{languages_str}}} at {yml_path}.") registered_project = serena_config.get_registered_project(str(project_root)) if registered_project is None: @@ -813,7 +815,7 @@ class ProjectCommands(AutoRegisteringGroup): collected_exceptions: list[Exception] = [] files_failed = [] - language_file_counts: dict[LanguageServerKey, int] = collections.defaultdict(lambda: 0) + language_file_counts: dict[LanguageServerIdLike, int] = collections.defaultdict(lambda: 0) last_save_time = time.monotonic() for i, f in enumerate(tqdm(files, desc="Indexing")): try: @@ -828,7 +830,7 @@ class ProjectCommands(AutoRegisteringGroup): if now - last_save_time >= 30: ls_mgr.save_all_caches() last_save_time = now - reported_language_file_counts = {k.value: v for k, v in language_file_counts.items()} + reported_language_file_counts = {k.get_key(): v for k, v in language_file_counts.items()} click.echo(f"Indexed files per language: {dict_string(reported_language_file_counts, brackets=None)}") ls_mgr.save_all_caches() @@ -894,7 +896,7 @@ class ProjectCommands(AutoRegisteringGroup): ls_mgr = proj.create_language_server_manager() try: ls = ls_mgr.get_language_server(file) - click.echo(f"Indexing for language {ls.ls_id.value} …") + click.echo(f"Indexing for language {ls.ls_id.get_key()} …") document_symbols = ls.request_document_symbols(file) symbols, _ = document_symbols.get_all_symbols_and_roots() if verbose: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index e682694f..bdd7699c 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -35,7 +35,7 @@ from serena.constants import ( from serena.util.inspection import compute_language_server_support_composition from serena.util.text_utils import GlobMatcher from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml_comments, save_yaml, transfer_yaml_comments -from solidlsp.ls_config import LanguageServerId, LanguageServerKey, registered_language_servers, resolve_language_server_id +from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike, LanguageServerRegistry from ..analytics import RegisteredTokenCountEstimator from ..util.class_decorators import singleton @@ -321,7 +321,7 @@ class ProjectConfigAutoGenerationMode(Enum): @dataclass(kw_only=True) class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name: str - language_servers: list[LanguageServerKey] + language_servers: list[LanguageServerIdLike] ignored_paths: list[str] = field(default_factory=list) ls_workspace_folders: list[str] = field(default_factory=lambda: ["."]) ls_additional_workspace_folders: list[str] = field(default_factory=list) @@ -358,7 +358,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): @classmethod def _determine_project_language_servers( cls, project_root: str, interactive: bool, serena_config: "SerenaConfig" - ) -> list[LanguageServerKey]: + ) -> list[LanguageServerIdLike]: log.info("Determining suitable language servers for the project") # determine language servers to be considered and their priorities @@ -452,7 +452,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): determined_languages = cls._determine_project_language_servers( str(project_root), interactive=interactive, serena_config=serena_config ) - languages_to_use = [l.value for l in determined_languages] + languages_to_use = [l.get_key() for l in determined_languages] else: languages_to_use = [lang.value for lang in languages] config_with_comments, _ = cls._load_yaml_dict(PROJECT_TEMPLATE_FILE) @@ -580,20 +580,14 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): """ # map languages to list of enum items, checking for errors lang_name_mapping = {"javascript": "typescript"} - ls_ids: list[LanguageServerKey] = [] + ls_ids: list[LanguageServerIdLike] = [] + ls_registry = LanguageServerRegistry.get_instance() for ls_str in data["language_servers"]: - orig_language_str = ls_str - try: - ls_str = ls_str.lower() - if ls_str in lang_name_mapping: - ls_str = lang_name_mapping[ls_str] - ls_id = resolve_language_server_id(ls_str) - ls_ids.append(ls_id) - except ValueError as e: - raise ValueError( - f"Invalid language server: '{orig_language_str}'.\nValid values are: " - f"{[l.value for l in LanguageServerId] + [l.value for l in registered_language_servers()]}" - ) from e + ls_str = ls_str.lower() + if ls_str in lang_name_mapping: + ls_str = lang_name_mapping[ls_str] + ls_id = ls_registry.resolve(ls_str) + ls_ids.append(ls_id) # Validate activation_command_timeout activation_command_timeout_raw = data.get("activation_command_timeout", 180.0) @@ -670,7 +664,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): del d[k] # map fields using non-primitive types to a YAML-compatible representation - d["language_servers"] = [lang.value for lang in self.language_servers] + d["language_servers"] = [lang.get_key() for lang in self.language_servers] d["language_backend"] = self.language_backend.value if self.language_backend is not None else None d["line_ending"] = self.line_ending.value if self.line_ending is not None else None @@ -694,12 +688,6 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): :param serena_config: the global Serena configuration :param autogen: the auto-generation mode to apply if the project configuration does not yet exist """ - from solidlsp.language_server_adapter_discovery import discover_registered_language_server_adapters - - # Discovery belongs at this boundary: external IDs must be registered before - # _from_dict resolves language_servers, while the resolver remains side-effect-free. - discover_registered_language_server_adapters() - project_root = Path(project_root) project_folder_name = project_root.name yaml_path = serena_config.get_project_yml_location(project_root) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 81fb571c..697d625b 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -29,6 +29,7 @@ from serena.util.logging import MemoryLogHandler from serena.util.pypi import PyPIPackageInfo from serena.util.pywebview import WebViewWithTray from serena.util.version import Version +from solidlsp.ls_config import LanguageServerRegistry if TYPE_CHECKING: from serena.agent import SerenaAgent @@ -513,7 +514,7 @@ class SerenaDashboardAPI: active_project_name = project.project_name if project else None project_info = { "name": active_project_name, - "language": ", ".join([l.value for l in project.project_config.language_servers]) if project else None, + "language": ", ".join([ls_id.get_key() for ls_id in project.project_config.language_servers]) if project else None, "path": str(project.project_root) if project else None, } @@ -605,9 +606,9 @@ class SerenaDashboardAPI: available_memories = project.memory_manager.list_memories().get_full_list() # Get list of languages for the active project - languages = [] + ls_ids = [] if project is not None: - languages = [lang.value for lang in project.project_config.language_servers] + ls_ids = [ls_id.get_key() for ls_id in project.project_config.language_servers] # Get file encoding for the active project encoding = None @@ -626,7 +627,7 @@ class SerenaDashboardAPI: available_contexts=available_contexts, available_memories=available_memories, jetbrains_mode=self._agent.get_language_backend().is_jetbrains(), - languages=languages, + languages=ls_ids, encoding=encoding, current_client=Tool.get_last_tool_call_client_str(), serena_version=self._agent.version, @@ -637,16 +638,13 @@ class SerenaDashboardAPI: self._current_config_overview = self._compute_config_overview().model_dump() def _get_available_languages(self) -> ResponseAvailableLanguages: - from solidlsp.ls_config import LanguageServerId, registered_language_servers - def run() -> ResponseAvailableLanguages: - all_languages = [lang.value for lang in LanguageServerId.iter_all(include_experimental=True)] - all_languages.extend(lang.value for lang in registered_language_servers()) + all_languages = LanguageServerRegistry.get_instance().get_keys() # Filter out already added languages for the active project project = self._agent.get_active_project() if project: - current_languages = [lang.value for lang in project.project_config.language_servers] + current_languages = [ls_id.get_key() for ls_id in project.project_config.language_servers] available_languages = [lang for lang in all_languages if lang not in current_languages] else: available_languages = all_languages @@ -776,22 +774,12 @@ class SerenaDashboardAPI: return {} def _add_language(self, request_add_language: RequestAddLanguage) -> None: - from solidlsp.ls_config import resolve_language_server_id - - try: - language = resolve_language_server_id(request_add_language.language) - except ValueError: - raise ValueError(f"Invalid language server identifier: {request_add_language.language}") + language = LanguageServerRegistry.get_instance().resolve(request_add_language.language) # add_language is already thread-safe self._agent.add_language_server(language) def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None: - from solidlsp.ls_config import resolve_language_server_id - - try: - language = resolve_language_server_id(request_remove_language.language) - except ValueError: - raise ValueError(f"Invalid language server identifier: {request_remove_language.language}") + language = LanguageServerRegistry.get_instance().resolve(request_remove_language.language) # remove_language is already thread-safe self._agent.remove_language_server(language) diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 1df2912d..a43875d9 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -9,7 +9,7 @@ from sensai.util.logging import LogTime from serena.config.serena_config import ProjectConfig, SerenaPaths from solidlsp import SolidLanguageServer -from solidlsp.ls_config import LanguageServerConfig, LanguageServerKey +from solidlsp.ls_config import LanguageServerConfig, LanguageServerIdLike from solidlsp.lsp_protocol_handler.lsp_types import DidChangeWatchedFilesParams, FileChangeType, FileEvent from solidlsp.settings import SolidLSPSettings @@ -45,7 +45,7 @@ class LanguageServerFactory: self.ls_specific_settings = ls_specific_settings self.trace_lsp_communication = trace_lsp_communication - def create_language_server(self, ls_id: LanguageServerKey) -> SolidLanguageServer: + def create_language_server(self, ls_id: LanguageServerIdLike) -> SolidLanguageServer: ls_config = LanguageServerConfig( workspace_folders=self.project_config.ls_workspace_folders, additional_workspace_folders=self.project_config.ls_additional_workspace_folders, @@ -75,7 +75,7 @@ class LanguageServerManager: def __init__( self, - language_servers: dict[LanguageServerKey, SolidLanguageServer], + language_servers: dict[LanguageServerIdLike, SolidLanguageServer], language_server_factory: LanguageServerFactory, project: "Project", ) -> None: @@ -97,7 +97,9 @@ class LanguageServerManager: return next(iter(self._language_servers.values())) @staticmethod - def from_languages(languages: list[LanguageServerKey], factory: LanguageServerFactory, project: "Project") -> "LanguageServerManager": + def from_languages( + languages: list[LanguageServerIdLike], factory: LanguageServerFactory, project: "Project" + ) -> "LanguageServerManager": """ Creates a manager with language servers for the given languages using the given factory. The language servers are started in parallel threads. @@ -109,21 +111,21 @@ class LanguageServerManager: """ class StartLSThread(threading.Thread): - def __init__(self, ls_id: LanguageServerKey): - super().__init__(target=self._start_language_server, name="StartLS:" + ls_id.value) + def __init__(self, ls_id: LanguageServerIdLike): + super().__init__(target=self._start_language_server, name="StartLS:" + ls_id.get_key()) self.ls_id = ls_id self.language_server: SolidLanguageServer | None = None self.exception: Exception | None = None def _start_language_server(self) -> None: try: - with LogTime(f"Language server startup (language={self.ls_id.value})"): + with LogTime(f"Language server startup (ls_id={self.ls_id.get_key()})"): self.language_server = factory.create_language_server(self.ls_id) self.language_server.start() if not self.language_server.is_running(): - raise RuntimeError(f"Failed to start the language server for language {self.ls_id.value}") + raise RuntimeError(f"Failed to start the language server {self.ls_id.get_key()}") except Exception as e: - log.error(f"Error starting language server for language {self.ls_id.value}: {e}", exc_info=e) + log.error(f"Error starting language server {self.ls_id.get_key()}: {e}", exc_info=e) self.exception = e # start language servers in parallel threads @@ -134,8 +136,8 @@ class LanguageServerManager: threads.append(thread) # collect language servers and exceptions - language_servers: dict[LanguageServerKey, SolidLanguageServer] = {} - exceptions: dict[LanguageServerKey, Exception] = {} + language_servers: dict[LanguageServerIdLike, SolidLanguageServer] = {} + exceptions: dict[LanguageServerIdLike, Exception] = {} for thread in threads: thread.join() if thread.exception is not None: @@ -154,7 +156,7 @@ class LanguageServerManager: if exceptions: for ls in language_servers.values(): ls.stop() - failure_messages = "\n".join([f"{lang.value}: {e}" for lang, e in exceptions.items()]) + failure_messages = "\n".join([f"{ls_id.get_key()}: {e}" for ls_id, e in exceptions.items()]) raise LanguageServerManagerInitialisationError(f"Failed to start {len(exceptions)} language server(s):\n{failure_messages}") return LanguageServerManager(language_servers, factory, project) @@ -183,7 +185,7 @@ class LanguageServerManager: ls = self._default_language_server return self._ensure_functional_ls(ls) - def _create_and_start_language_server(self, ls_id: LanguageServerKey) -> SolidLanguageServer: + def _create_and_start_language_server(self, ls_id: LanguageServerIdLike) -> SolidLanguageServer: if self._language_server_factory is None: raise ValueError(f"No language server factory available to create language server for {ls_id}") language_server = self._language_server_factory.create_language_server(ls_id) @@ -191,19 +193,19 @@ class LanguageServerManager: self._language_servers[ls_id] = language_server return language_server - def restart_language_server(self, language: LanguageServerKey) -> SolidLanguageServer: + def restart_language_server(self, ls_id: LanguageServerIdLike) -> SolidLanguageServer: """ Forces recreation and restart of the language server for the given language. It is assumed that the language server for the given language is no longer running. - :param language: the language + :param ls_id: the language server identifier :return: the newly created language server """ - if language not in self._language_servers: - raise ValueError(f"No language server for language {language.value} present; cannot restart") - return self._create_and_start_language_server(language) + if ls_id not in self._language_servers: + raise ValueError(f"No language server for language {ls_id.get_key()} present; cannot restart") + return self._create_and_start_language_server(ls_id) - def add_language_server(self, ls_id: LanguageServerKey) -> SolidLanguageServer: + def add_language_server(self, ls_id: LanguageServerIdLike) -> SolidLanguageServer: """ Dynamically adds a new language server for the given language. @@ -211,21 +213,21 @@ class LanguageServerManager: :return: the newly created language server """ if ls_id in self._language_servers: - raise ValueError(f"Language server for language {ls_id.value} already present") + raise ValueError(f"Language server {ls_id.get_key()} already present") return self._create_and_start_language_server(ls_id) - def remove_language_server(self, language: LanguageServerKey, save_cache: bool = False) -> None: + def remove_language_server(self, ls_id: LanguageServerIdLike, save_cache: bool = False) -> None: """ Removes the language server for the given language, stopping it if it is running. - :param language: the language + :param ls_id: the language """ - if language not in self._language_servers: - raise ValueError(f"No language server for language {language.value} present; cannot remove") - ls = self._language_servers.pop(language) + if ls_id not in self._language_servers: + raise ValueError(f"No language server for language {ls_id.get_key()} present; cannot remove") + ls = self._language_servers.pop(ls_id) self._stop_language_server(ls, save_cache=save_cache) - def get_active_language_server_ids(self) -> list[LanguageServerKey]: + def get_active_language_server_ids(self) -> list[LanguageServerIdLike]: """ Returns the list of languages for which language servers are currently managed. diff --git a/src/serena/project.py b/src/serena/project.py index 1d18a16f..8f713d6f 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -21,7 +21,7 @@ from serena.util.file_proxy import FileCollection, FileProxy from serena.util.file_system import GitignoreParser, match_path, scan_directory from serena.util.text_utils import MatchedConsecutiveLines, search_files from solidlsp import SolidLanguageServer -from solidlsp.ls_config import LanguageServerKey +from solidlsp.ls_config import LanguageServerIdLike if TYPE_CHECKING: from serena.agent import SerenaAgent @@ -567,7 +567,7 @@ class Project(ToStringMixin): raise Exception(msg.build()) return self.language_server_manager - def add_language_server(self, ls_id: LanguageServerKey) -> None: + def add_language_server(self, ls_id: LanguageServerIdLike) -> None: """ Adds a new language server to the project configuration, starting the corresponding server instance if the LS manager is active. @@ -576,21 +576,21 @@ class Project(ToStringMixin): :param ls_id: the language server to add """ if ls_id in self.project_config.language_servers: - log.info(f"Language server {ls_id.value} is already present in the project configuration.") + log.info(f"Language server {ls_id.get_key()} is already present in the project configuration.") return # start the language server (if the LS manager is active) if self.language_server_manager is None: log.info("Language server manager is not active; skipping language server startup for the new language.") else: - log.info("Adding and starting the language server '%s' ...", ls_id.value) + log.info("Adding and starting the language server '%s' ...", ls_id.get_key()) self.language_server_manager.add_language_server(ls_id) # update the project configuration self.project_config.language_servers.append(ls_id) self.save_config() - def remove_language_server(self, ls_id: LanguageServerKey) -> None: + def remove_language_server(self, ls_id: LanguageServerIdLike) -> None: """ Removes a language server from the project configuration, stopping the corresponding server instance if the LS manager is active. @@ -599,7 +599,7 @@ class Project(ToStringMixin): :param ls_id: the language server to remove """ if ls_id not in self.project_config.language_servers: - log.info(f"Language {ls_id.value} is not present in the project configuration.") + log.info(f"Language {ls_id.get_key()} is not present in the project configuration.") return # update the project configuration self.project_config.language_servers.remove(ls_id) @@ -609,7 +609,7 @@ class Project(ToStringMixin): if self.language_server_manager is None: log.info("Language server manager is not active; skipping language server shutdown for the removed language.") else: - log.info("Removing and stopping the language server for language %s ...", ls_id.value) + log.info("Removing and stopping the language server for language %s ...", ls_id.get_key()) self.language_server_manager.remove_language_server(ls_id) def ls_sync_file_system_changes(self) -> int: diff --git a/src/solidlsp/language_server_adapter_discovery.py b/src/solidlsp/language_server_adapter_discovery.py deleted file mode 100644 index a7d89ff7..00000000 --- a/src/solidlsp/language_server_adapter_discovery.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Discovers explicitly installed SolidLSP language-server adapters.""" - -import logging -import threading -from dataclasses import dataclass -from importlib import metadata - -from solidlsp.ls_config import _restore_registered_language_servers, _snapshot_registered_language_servers - -log = logging.getLogger(__name__) - -ENTRY_POINT_GROUP = "serena.language_servers" - - -@dataclass -class _DiscoveryState: - completed: bool = False - - -_discovery_state = _DiscoveryState() -_discovery_lock = threading.Lock() - - -def discover_registered_language_server_adapters() -> None: - """Load installed adapters before project configuration resolves language-server IDs. - - First discovery is serialized because registration and rollback mutate one - process-global registry. Metadata-enumeration failures leave discovery - incomplete so a later project load can retry. After successful enumeration, - each entry point is applied transactionally; a failing adapter is rolled back - without preventing the remaining installed adapters from registering. - """ - with _discovery_lock: - if _discovery_state.completed: - return - - try: - entry_points = metadata.entry_points(group=ENTRY_POINT_GROUP) - except Exception as error: - log.exception("Failed to discover language server adapter entry points: %s", error) - return - - for entry_point in entry_points: - registry_snapshot = _snapshot_registered_language_servers() - try: - registration = entry_point.load() - if not callable(registration): - raise TypeError("entry point must resolve to a callable registration function") - registration() - except Exception as error: - _restore_registered_language_servers(registry_snapshot) - log.exception( - "Failed to load language server adapter entry point '%s' from %s: %s", - entry_point.name, - _get_distribution_name(entry_point), - error, - ) - - _discovery_state.completed = True - - -def _get_distribution_name(entry_point: metadata.EntryPoint) -> str: - distribution = getattr(entry_point, "dist", None) - if distribution is None: - return "unknown distribution" - return distribution.name or "unknown distribution" - - -def _reset_language_server_adapter_discovery_for_tests() -> None: - """Reset discovery state for deterministic in-process tests.""" - with _discovery_lock: - _discovery_state.completed = False diff --git a/src/solidlsp/language_servers/angular_language_server.py b/src/solidlsp/language_servers/angular_language_server.py index c25ed837..123abba8 100644 --- a/src/solidlsp/language_servers/angular_language_server.py +++ b/src/solidlsp/language_servers/angular_language_server.py @@ -95,11 +95,6 @@ class AngularTypeScriptServer(TypeScriptLanguageServer): cross-file references that span Angular templates. """ - @classmethod - @override - def get_language_server_id(cls) -> LanguageServerId: - return LanguageServerId.TYPESCRIPT - def get_source_fn_matcher(self) -> FilenameMatcher: # Use the Angular matcher so .html template files aren't filtered out of # reference / search results when the companion is asked about them. diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index dabe2284..a4e516de 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -532,7 +532,7 @@ class ScalaLanguageServer(SolidLanguageServer): config, repository_root_path, ProcessLaunchInfo(cmd=scala_lsp_executable_path, cwd=repository_root_path), - config.ls_id.value, + "scala", solidlsp_settings, ) diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index e716dea0..33e9f01f 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -98,12 +98,6 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer): self._explicit_ts_ls_executable = ts_ls_executable_path super().__init__(config, repository_root_path, solidlsp_settings) - @classmethod - @override - def get_language_server_id(cls) -> LanguageServerId: - """Return TYPESCRIPT; companion uses the TypeScript LS infrastructure.""" - return LanguageServerId.TYPESCRIPT - @override def get_source_fn_matcher(self) -> FilenameMatcher: # include .svelte so references returned by the plugin are not filtered out diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index 74519276..d984cbeb 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -37,17 +37,6 @@ log = logging.getLogger(__name__) class VueTypeScriptServer(TypeScriptLanguageServer): """TypeScript LS configured with @vue/typescript-plugin for Vue file support.""" - @classmethod - @override - def get_language_server_id(cls) -> LanguageServerId: - """Return TYPESCRIPT since this is a TypeScript language server variant. - - Note: VueTypeScriptServer is a companion server that uses TypeScript's language server - with the Vue TypeScript plugin. It reports as TYPESCRIPT to maintain compatibility - with the TypeScript language server infrastructure. - """ - return LanguageServerId.TYPESCRIPT - def get_source_fn_matcher(self) -> FilenameMatcher: # must override with Vue-specific matcher to ensure .vue files are included (as they can be discovered via references, # for instance; otherwise, we may find references in .vue files but then filter the results out, because .vue files are ignored.) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 552927d2..1a31481e 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -31,7 +31,7 @@ from solidlsp.dependency_provider import ( LanguageServerDependencyProviderUvx, ) from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder -from solidlsp.ls_config import FilenameMatcher, LanguageServerConfig, LanguageServerId, LanguageServerKey +from solidlsp.ls_config import FilenameMatcher, LanguageServerConfig from solidlsp.ls_exceptions import InvalidTextLocationError, SolidLSPException from solidlsp.ls_process import DEFAULT_LS_REQUEST_TIMEOUT, LanguageServerInterface, StdioLanguageServer from solidlsp.ls_types import UnifiedSymbolInformation @@ -415,10 +415,6 @@ class SolidLanguageServer(ABC): else: return logging.INFO - @classmethod - def get_language_server_id(cls) -> LanguageServerKey: - return LanguageServerId.from_ls_class(cls) - @classmethod def supports_implementation_request(cls) -> bool: """ @@ -514,7 +510,7 @@ class SolidLanguageServer(ABC): """ self.config = config self._solidlsp_settings = solidlsp_settings - ls_id = self.get_language_server_id() + ls_id = config.ls_id self._custom_settings = solidlsp_settings.get_ls_specific_settings(ls_id) """ the (user-provided) language server-specific settings @@ -533,7 +529,7 @@ class SolidLanguageServer(ABC): default language identifier to be passed to the language server in `textDocument/didOpen` notifications. """ self.open_file_buffers: dict[str, LSPFileBuffer] = {} - self.ls_id = self.get_language_server_id() + self.ls_id = ls_id """ identifies the language server (not to be confused with the language_id passed to the language server) """ diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index b7ff558d..2d857b42 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -4,15 +4,17 @@ Configuration objects for language servers from __future__ import annotations +import importlib import logging import os import re +import threading from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum from functools import cache from pathlib import Path -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Protocol, Self if TYPE_CHECKING: from solidlsp import SolidLanguageServer @@ -89,124 +91,34 @@ class FilenameMatcher: return False -class RegisteredLanguageServerId(str): - """Canonical external language-server ID carrying its matcher and implementation class. - - The registry enforces a one-to-one mapping between IDs and implementation - classes across both built-in and external language servers. +class LanguageServerIdLike(Protocol): + """ + Protocol defining the interface for language server identifiers, + which can be either a :class:`LanguageServerId` enum member or any other object that implements the same methods. + For language server adapters defined outside the SolidLSP library, use :class:`ExternalLanguageServerId`. """ - _matcher: FilenameMatcher - _implementation: type["SolidLanguageServer"] - - def __new__(cls, value: str, matcher: FilenameMatcher, implementation: type["SolidLanguageServer"]): - canonical_value = value.strip() - if not canonical_value: - raise ValueError("Language server registration requires a non-empty id") - if value != canonical_value: - raise ValueError(f"Language server id must be trimmed: {value!r}") - if value != value.lower(): - raise ValueError(f"Language server id must be lowercase: {value!r}") - instance = super().__new__(cls, value) - instance._matcher: FilenameMatcher = matcher - instance._implementation: type["SolidLanguageServer"] = implementation - return instance - - def __deepcopy__(self, memo: dict[int, object]) -> "RegisteredLanguageServerId": - return self - - @property - def value(self) -> str: - return str(self) + def get_key(self) -> str: + """ + :return: the string key which identifies this language server in configuration files + """ def get_source_fn_matcher(self) -> FilenameMatcher: - return self._matcher + pass def get_ls_class(self) -> type["SolidLanguageServer"]: - return self._implementation + pass def get_priority(self) -> int: - return 0 + pass def supports_implementation_request(self) -> bool: return self.get_ls_class().supports_implementation_request() -_registered_language_servers: dict[str, RegisteredLanguageServerId] = {} - - -def register_ls( - id: str, - matcher: FilenameMatcher, - implementation: type["SolidLanguageServer"], -) -> RegisteredLanguageServerId: - """Register an external language server explicitly before project configuration loads. - - A trusted Python host imports the adapter module before Serena resolves the - project's ``language_servers``. Project configuration never imports Python code. - - IDs must be non-empty, trimmed, and lowercase. Duplicate IDs and reuse of an - implementation class are rejected because runtime identity is derived from - both the configured ID and its implementation class. +class LanguageServerId(Enum): """ - registered_id = RegisteredLanguageServerId(id, matcher, implementation) - if id in {language.value for language in LanguageServerId}: - raise ValueError(f"Language server id is already built in: {id}") - if id in _registered_language_servers: - raise ValueError(f"Language server id is already registered: {id}") - for built_in_id in LanguageServerId: - if built_in_id.get_ls_class() is implementation: - raise ValueError( - f"Cannot register external language server id {id!r}: implementation {implementation.__name__} " - f"is already assigned to built-in id {built_in_id.value!r}" - ) - for existing_id in _registered_language_servers.values(): - if existing_id.get_ls_class() is implementation: - raise ValueError( - f"Language server implementation {implementation.__name__} is already registered under external id: {existing_id.value}" - ) - _registered_language_servers[id] = registered_id - return registered_id - - -def resolve_language_server_id(value: str) -> LanguageServerKey: - """Resolve a built-in or explicitly registered ID without triggering discovery. - - Adapter discovery is owned by the project-configuration loading lifecycle. - """ - try: - return LanguageServerId(value) - except ValueError: - try: - return _registered_language_servers[value] - except KeyError as error: - raise ValueError(f"Unknown language server: {value}") from error - - -def registered_language_servers() -> tuple[RegisteredLanguageServerId, ...]: - """Return a snapshot of the explicitly registered external language servers.""" - return tuple(_registered_language_servers.values()) - - -def _reset_registered_language_servers_for_tests() -> None: - """Clear explicit registrations for deterministic in-process tests.""" - _registered_language_servers.clear() - - -def _snapshot_registered_language_servers() -> dict[str, RegisteredLanguageServerId]: - """Return the current registrations for transactional adapter discovery.""" - return dict(_registered_language_servers) - - -def _restore_registered_language_servers(snapshot: dict[str, RegisteredLanguageServerId]) -> None: - """Restore registrations after a failing adapter entry point.""" - _registered_language_servers.clear() - _registered_language_servers.update(snapshot) - - -class LanguageServerId(str, Enum): - """ - Enumeration of language servers supported by SolidLSP. + Enumeration of language servers directly supported by SolidLSP. """ CSHARP = "csharp" @@ -437,6 +349,9 @@ class LanguageServerId(str, Enum): if include_non_programming_languages or lang.is_programming_language(): yield lang + def get_key(self) -> str: + return self.value + def is_experimental(self) -> bool: """ Check if the language server is experimental (potentially not robust), @@ -1055,25 +970,117 @@ class LanguageServerId(str, Enum): case _: raise ValueError(f"Unhandled language: {self}") + +class ExternalLanguageServerId(LanguageServerIdLike): + """ + Represents an identifier for a language server that is externally provided and registered. + """ + + def __init__(self, key: str, matcher: FilenameMatcher, implementation: type["SolidLanguageServer"], priority: int = 0): + self._key = key + self._matcher = matcher + self._implementation = implementation + self._priority = priority + + def get_key(self) -> str: + return self._key + + def get_source_fn_matcher(self) -> FilenameMatcher: + return self._matcher + + def get_ls_class(self) -> type["SolidLanguageServer"]: + return self._implementation + + def get_priority(self) -> int: + return self._priority + + +class LanguageServerRegistry: + """ + Registry of language servers + """ + + REGISTRATION_ENTRY_POINT_GROUP = "solidlsp.language_server_registration" + """ + entry point group for language server registration functions; each function should call use + `LanguageServerRegistry.get_instance().register(...)` to register a language server + """ + + _instance = None + _instance_lock = threading.Lock() + @classmethod - def from_ls_class(cls, ls_class: type["SolidLanguageServer"]) -> LanguageServerKey: + def get_instance(cls): + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls(True) + cls._discover_language_server_registrations_from_entry_points() + return cls._instance + + def __init__(self, _singleton: bool): + if not _singleton: + raise RuntimeError("LanguageServerRegistry is a singleton. Use get_instance() to access it.") + self._registered_language_servers: dict[str, LanguageServerIdLike] = {} + + # auto-register built-in language servers + for ls_id in LanguageServerId: + self._registered_language_servers[ls_id.get_key()] = ls_id + + @classmethod + def _discover_language_server_registrations_from_entry_points(cls) -> None: """ - Get the Language enum value from a SolidLanguageServer class. - - :param ls_class: The SolidLanguageServer class to find the corresponding Language for - :return: The Language enum value - :raises ValueError: If the language server class is not supported + Discover and execute language server adapter registration functions from entry points. """ - for enum_instance in cls: - if enum_instance.get_ls_class() == ls_class: - return enum_instance - for registered_id in registered_language_servers(): - if registered_id.get_ls_class() == ls_class: - return registered_id - raise ValueError(f"Unhandled language server class: {ls_class}") + log.debug("Discovering language server registration entry points ...") + try: + entry_points = importlib.metadata.entry_points(group=cls.REGISTRATION_ENTRY_POINT_GROUP) + except Exception as error: + log.exception("Failed to discover language server registration entry points: %s", error) + return + def get_distribution_name(ep: importlib.metadata.EntryPoint) -> str: + distribution = getattr(ep, "dist", None) + if distribution is None: + return "unknown distribution" + return distribution.name or "unknown distribution" -LanguageServerKey = LanguageServerId | RegisteredLanguageServerId + log.debug("Found %d language server registration entry points", len(entry_points)) + for entry_point in entry_points: + try: + registration = entry_point.load() + if not callable(registration): + raise TypeError("entry point must resolve to a callable registration function") + registration() + except Exception as error: + log.exception( + "Failed to load language server adapter entry point '%s' from %s: %s", + entry_point.name, + get_distribution_name(entry_point), + error, + ) + + def resolve(self, key: str) -> LanguageServerIdLike: + if key in self._registered_language_servers: + return self._registered_language_servers[key] + raise ValueError(f"Unknown language server key: '{key}'; Valid keys: {self.get_keys()}") + + def register(self, ls_id: LanguageServerIdLike, allow_override: bool = False) -> None: + """ + :param ls_id: the identifier to register + :param allow_override: whether to allow overriding an existing registration with the same key + """ + log.info("Registering language server: %s (class=%s)", ls_id.get_key(), ls_id.get_ls_class().__name__) + key = ls_id.get_key() + if key in self._registered_language_servers and not allow_override: + raise ValueError(f"Language server id is already registered: {key}") + self._registered_language_servers[key] = ls_id + + def get_keys(self) -> list[str]: + """ + :return: the sorted list of all registered string keys + """ + return sorted(self._registered_language_servers.keys()) @dataclass(frozen=True) @@ -1082,7 +1089,7 @@ class LanguageServerConfig: Configuration parameters for a language server instance """ - ls_id: LanguageServerKey + ls_id: LanguageServerIdLike """ defines the language server to use """ diff --git a/src/solidlsp/ls_exceptions.py b/src/solidlsp/ls_exceptions.py index e86e7999..0d439d52 100644 --- a/src/solidlsp/ls_exceptions.py +++ b/src/solidlsp/ls_exceptions.py @@ -2,7 +2,7 @@ This module contains the exceptions raised by the framework. """ -from solidlsp.ls_config import LanguageServerKey +from solidlsp.ls_config import LanguageServerIdLike class SolidLSPException(Exception): @@ -28,7 +28,7 @@ class SolidLSPException(Exception): return isinstance(self.cause, LanguageServerTerminatedException) - def get_affected_language(self) -> LanguageServerKey | None: + def get_affected_language(self) -> LanguageServerIdLike | None: """ :return: the affected language for the case where the exception is caused by the language server having terminated """ diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index 52664428..9ba15dfa 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -12,7 +12,7 @@ from typing import IO, Any, AnyStr from sensai.util.string import ToStringMixin -from solidlsp.ls_config import LanguageServerKey +from solidlsp.ls_config import LanguageServerIdLike from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_request import LanguageServerRequest from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification @@ -58,7 +58,7 @@ class LanguageServerTerminatedException(Exception): Exception raised when the language server process has terminated unexpectedly. """ - def __init__(self, message: str, ls_id: LanguageServerKey, cause: Exception | None = None) -> None: + def __init__(self, message: str, ls_id: LanguageServerIdLike, cause: Exception | None = None) -> None: super().__init__(message) self.message = message self.ls_id = ls_id @@ -121,7 +121,7 @@ class LanguageServerInterface(ABC): def __init__( self, - ls_id: LanguageServerKey, + ls_id: LanguageServerIdLike, determine_log_level: Callable[[str], int], logger: Callable[[str, str, StringDict | str], None] | None = None, request_timeout: float | None = None, @@ -491,7 +491,7 @@ class StdioLanguageServer(LanguageServerInterface): def __init__( self, process_launch_info: ProcessLaunchInfo, - ls_id: LanguageServerKey, + ls_id: LanguageServerIdLike, determine_log_level: Callable[[str], int], logger: Callable[[str, str, StringDict | str], None] | None = None, start_independent_lsp_process: bool = True, @@ -519,7 +519,7 @@ class StdioLanguageServer(LanguageServerInterface): log.info("Starting language server process via command: %s", self._process_launch_info.cmd) process = subprocess_util.ManagedSubprocessLauncher.get_instance().launch( - self._process_launch_info, name=f"LS[{self.ls_id.value}]", start_new_session=self._start_independent_lsp_process + self._process_launch_info, name=f"LS[{self.ls_id.get_key()}]", start_new_session=self._start_independent_lsp_process ) self._process = process @@ -534,12 +534,12 @@ class StdioLanguageServer(LanguageServerInterface): # start threads to read stdout and stderr of the process threading.Thread( target=self._read_ls_process_stdout, - name=f"LSP-stdout-reader:{self.ls_id.value}", + name=f"LSP-stdout-reader:{self.ls_id.get_key()}", daemon=True, ).start() threading.Thread( target=self._read_ls_process_stderr, - name=f"LSP-stderr-reader:{self.ls_id.value}", + name=f"LSP-stderr-reader:{self.ls_id.get_key()}", daemon=True, ).start() @@ -689,7 +689,7 @@ class TCPLanguageServer(LanguageServerInterface): def __init__( self, connection_info: TCPConnectionInfo, - ls_id: LanguageServerKey, + ls_id: LanguageServerIdLike, determine_log_level: Callable[[str], int], logger: Callable[[str, str, StringDict | str], None] | None = None, request_timeout: float | None = None, @@ -732,7 +732,7 @@ class TCPLanguageServer(LanguageServerInterface): threading.Thread( target=self._read_loop, - name=f"LSP-tcp-reader:{self.ls_id.value}", + name=f"LSP-tcp-reader:{self.ls_id.get_key()}", daemon=True, ).start() diff --git a/src/solidlsp/settings.py b/src/solidlsp/settings.py index ec7a9d5b..5aa94d4e 100644 --- a/src/solidlsp/settings.py +++ b/src/solidlsp/settings.py @@ -6,12 +6,13 @@ import logging import os import pathlib from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union +from sensai.util.helper import count_not_none from sensai.util.string import ToStringMixin if TYPE_CHECKING: - from solidlsp.ls_config import LanguageServerKey + from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike log = logging.getLogger(__name__) @@ -37,11 +38,12 @@ class SolidLSPSettings: For instance, if this is "/home/user/myproject/.solidlsp", then Solid-LSP will store project-specific data (e.g. caches) in that directory. """ - ls_specific_settings: dict["LanguageServerKey", dict[str, Any]] = field(default_factory=dict) + ls_specific_settings: dict[Union[str, "LanguageServerId"], dict[str, Any]] = field(default_factory=dict) """ Advanced configuration option allowing to configure language server implementation specific options. - Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available. - No documentation on options means no options are available. + Maps language server identifiers or their string keys to a dictionary of options for that language server. + See constructors of the corresponding LS implementations within SolidLSP to see which options are available. + Some options are centrally supported. See Serena's documentation for details. """ def __post_init__(self) -> None: @@ -76,11 +78,19 @@ class SolidLSPSettings: value = default_value return value - def get_ls_specific_settings(self, ls_id: "LanguageServerKey") -> CustomLSSettings: + def get_ls_specific_settings(self, ls_id: "LanguageServerIdLike") -> CustomLSSettings: """ Gets the custom settings for the given language server :param ls_id: the language server identifier for which to retrieve settings :return: a dictionary of settings for the language server """ - return self.CustomLSSettings(self.ls_specific_settings.get(ls_id)) + from solidlsp.ls_config import LanguageServerId + + settings_dict = self.ls_specific_settings.get(ls_id.get_key()) + if isinstance(ls_id, LanguageServerId): + settings_dict2 = self.ls_specific_settings.get(ls_id) + if count_not_none(settings_dict, settings_dict2) > 1: + raise ValueError(f"Duplicate LS-specific settings for {ls_id.get_key()}: Found keys {ls_id.get_key()} and {ls_id}") + settings_dict = settings_dict or settings_dict2 + return self.CustomLSSettings(settings_dict) diff --git a/test/conftest.py b/test/conftest.py index e18fe3fc..204127b6 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -65,8 +65,8 @@ PYTHON_LANGUAGE_BACKENDS = [LanguageServerId.PYTHON, LanguageServerId.PYTHON_TY, def get_repo_path(language: LanguageServerId) -> Path: - repo_language = _LANGUAGE_REPO_ALIASES.get(language, language) - return Path(__file__).parent / "resources" / "repos" / repo_language / "test_repo" + ls_id = _LANGUAGE_REPO_ALIASES.get(language, language) + return Path(__file__).parent / "resources" / "repos" / ls_id.get_key() / "test_repo" def _create_ls( @@ -325,7 +325,7 @@ def get_pytest_markers(ls_id: LanguageServerId) -> list[MarkDecorator | Mark]: """ return [ *_LANGUAGE_PYTEST_MARKERS[ls_id], - pytest.mark.skipif(not language_server_tests_enabled(ls_id), reason=f"{ls_id.value} tests are disabled in this environment"), + pytest.mark.skipif(not language_server_tests_enabled(ls_id), reason=f"{ls_id.get_key()} tests are disabled in this environment"), ] diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py index b178f52b..7f003c5b 100644 --- a/test/serena/test_dashboard.py +++ b/test/serena/test_dashboard.py @@ -1,48 +1,8 @@ from collections.abc import Callable from types import SimpleNamespace -import pytest - -from serena.agent import AvailableTools, ProjectPromptProvisionStatus, SerenaAgent -from serena.config.context_mode import SerenaAgentContext -from serena.config.serena_config import LanguageBackend from serena.dashboard import SerenaDashboardAPI -from solidlsp import SolidLanguageServer -from solidlsp.ls_config import ( - FilenameMatcher, - LanguageServerConfig, - LanguageServerId, - LanguageServerKey, - RegisteredLanguageServerId, - _reset_registered_language_servers_for_tests, - register_ls, -) -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo -from solidlsp.settings import SolidLSPSettings - - -class DummyExternalLanguageServer(SolidLanguageServer): - def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): - super().__init__( - config, repository_root_path, ProcessLaunchInfo(cmd=["dummy"], cwd=repository_root_path), "dummy", solidlsp_settings - ) - - def _create_base_initialize_params(self) -> dict: - return {} - - def _start_server(self) -> None: - self.server.start() - - -@pytest.fixture(autouse=True) -def isolated_registry(): - _reset_registered_language_servers_for_tests() - yield - _reset_registered_language_servers_for_tests() - - -def _register_external_language_server() -> RegisteredLanguageServerId: - return register_ls("external-test", FilenameMatcher(".external"), DummyExternalLanguageServer) +from solidlsp.ls_config import LanguageServerId class _DummyMemoryLogHandler: @@ -56,12 +16,6 @@ class _DummyMemoryLogHandler: class _DummyAgent: def __init__(self, project: SimpleNamespace | None) -> None: self._project = project - self.added_languages: list[LanguageServerKey] = [] - self.removed_languages: list[LanguageServerKey] = [] - self.serena_config = SimpleNamespace(projects=[]) - self.version = "test" - self._all_tools = {} - self._context = SerenaAgentContext.from_name("desktop-app") def register_config_changed_callback(self, callback: Callable[[], None]) -> None: pass @@ -73,44 +27,13 @@ class _DummyAgent: def get_active_project(self): return self._project - def get_context(self): - return self._context - def get_active_modes(self): - return SimpleNamespace(get_modes=lambda include_background_base_modes=False: []) - - def get_active_tool_names(self): - return [] - - def tool_is_active(self, tool_name: str) -> bool: - return False - - def get_language_backend(self): - return SimpleNamespace(is_jetbrains=lambda: False) - - def add_language_server(self, language: LanguageServerKey) -> None: - self.added_languages.append(language) - - def remove_language_server(self, language: LanguageServerKey) -> None: - self.removed_languages.append(language) - - -def _make_dashboard(project_languages: list[LanguageServerKey] | None) -> SerenaDashboardAPI: +def _make_dashboard(project_languages: list[LanguageServerId] | None) -> SerenaDashboardAPI: project = None if project_languages is not None: - project = SimpleNamespace( - project_name="test-project", - project_root="/test/project", - project_config=SimpleNamespace(language_servers=project_languages, encoding="utf-8"), - ) + project = SimpleNamespace(project_config=SimpleNamespace(language_servers=project_languages)) agent = _DummyAgent(project) - dashboard = SerenaDashboardAPI.__new__(SerenaDashboardAPI) - dashboard._agent = agent - dashboard._memory_log_handler = _DummyMemoryLogHandler() - dashboard._tool_names = [] - dashboard._tool_usage_stats = None - dashboard._newer_serena_version = None - return dashboard + return SerenaDashboardAPI(memory_log_handler=_DummyMemoryLogHandler(), tool_names=[], agent=agent, tool_usage_stats=None) def test_available_languages_include_experimental_when_no_active_project(): @@ -128,90 +51,3 @@ def test_available_languages_exclude_project_languages(): assert LanguageServerId.MARKDOWN.value not in available # ensure experimental languages remain available for selection assert LanguageServerId.ANSIBLE.value in available - - -def test_available_languages_include_registered_external_language_server(): - _register_external_language_server() - - response = _make_dashboard(project_languages=None)._get_available_languages() - - assert "external-test" in response.languages - assert LanguageServerId.PYTHON.value in response.languages - assert response.languages == sorted(response.languages) - - -def test_available_languages_exclude_configured_external_language_server(): - external_id = _register_external_language_server() - - response = _make_dashboard(project_languages=[external_id])._get_available_languages() - - assert "external-test" not in response.languages - - -def test_add_and_remove_registered_external_language_server(): - external_id = _register_external_language_server() - dashboard = _make_dashboard(project_languages=[LanguageServerId.PYTHON]) - agent = dashboard._agent - - dashboard._add_language(SimpleNamespace(language="external-test")) - dashboard._remove_language(SimpleNamespace(language="external-test")) - - assert agent.added_languages == [external_id] - assert agent.removed_languages == [external_id] - - -def test_add_and_remove_builtin_language_server_remain_supported(): - dashboard = _make_dashboard(project_languages=[]) - agent = dashboard._agent - - dashboard._add_language(SimpleNamespace(language="python")) - dashboard._remove_language(SimpleNamespace(language="python")) - - assert agent.added_languages == [LanguageServerId.PYTHON] - assert agent.removed_languages == [LanguageServerId.PYTHON] - - -def test_add_and_remove_unknown_language_server_are_rejected(): - dashboard = _make_dashboard(project_languages=[]) - - with pytest.raises(ValueError, match="Invalid language server identifier: missing-external"): - dashboard._add_language(SimpleNamespace(language="missing-external")) - with pytest.raises(ValueError, match="Invalid language server identifier: missing-external"): - dashboard._remove_language(SimpleNamespace(language="missing-external")) - - -def test_config_overview_includes_builtin_and_registered_external_language_servers(): - external_id = _register_external_language_server() - dashboard = _make_dashboard(project_languages=[LanguageServerId.PYTHON, external_id]) - - overview = dashboard._compute_config_overview() - - assert overview.languages == ["python", "external-test"] - assert overview.active_project["language"] == "python, external-test" - - -def test_project_activation_message_includes_builtin_and_registered_external_language_servers(): - external_id = _register_external_language_server() - agent = SerenaAgent.__new__(SerenaAgent) - agent._active_project = SimpleNamespace( - is_newly_created=False, - project_name="test-project", - project_root="/test/project", - project_config=SimpleNamespace( - language_servers=[LanguageServerId.PYTHON, external_id], - encoding="utf-8", - initial_prompt="", - ), - ) - agent._language_backend = LanguageBackend.LSP - agent._active_tools = AvailableTools([]) - agent._project_prompt_status = ProjectPromptProvisionStatus() - agent._gui_log_viewer = None - agent._dashboard_manager = None - - try: - message = agent.get_project_activation_message("test-session") - finally: - agent._active_project = None - - assert "Active language servers: python, external-test." in message diff --git a/test/solidlsp/bsl/test_bsl_basic.py b/test/solidlsp/bsl/test_bsl_basic.py index 314b7d5a..af6138c5 100644 --- a/test/solidlsp/bsl/test_bsl_basic.py +++ b/test/solidlsp/bsl/test_bsl_basic.py @@ -176,7 +176,7 @@ def test_bsl_dependency_provider_custom_version_no_sha() -> None: from solidlsp.language_servers.common import RuntimeDependencyCollection settings = SolidLSPSettings() - settings.ls_specific_settings[LanguageServerId.BSL] = {"bsl_ls_version": "0.28.0"} + settings.ls_specific_settings[LanguageServerId.BSL.get_key()] = {"bsl_ls_version": "0.28.0"} provider = BSLLanguageServer.DependencyProvider( settings.get_ls_specific_settings(LanguageServerId.BSL), "/tmp/ls_resources", @@ -216,7 +216,7 @@ def test_bsl_launch_command_uses_ls_path_without_download() -> None: from solidlsp.language_servers.bsl_language_server import BSLLanguageServer settings = SolidLSPSettings() - settings.ls_specific_settings[LanguageServerId.BSL] = {"ls_path": "/custom/path/bsl-language-server.jar"} + settings.ls_specific_settings[LanguageServerId.BSL.get_key()] = {"ls_path": "/custom/path/bsl-language-server.jar"} provider = BSLLanguageServer.DependencyProvider( settings.get_ls_specific_settings(LanguageServerId.BSL), "/tmp/ls_resources", @@ -243,7 +243,7 @@ def test_bsl_launch_command_requires_java() -> None: from solidlsp.language_servers.bsl_language_server import BSLLanguageServer settings = SolidLSPSettings() - settings.ls_specific_settings[LanguageServerId.BSL] = {"ls_path": "/custom/path/bsl-language-server.jar"} + settings.ls_specific_settings[LanguageServerId.BSL.get_key()] = {"ls_path": "/custom/path/bsl-language-server.jar"} provider = BSLLanguageServer.DependencyProvider( settings.get_ls_specific_settings(LanguageServerId.BSL), "/tmp/ls_resources", @@ -260,7 +260,7 @@ def test_bsl_launch_command_rejects_old_java() -> None: from solidlsp.language_servers.bsl_language_server import BSL_LS_MIN_JAVA_VERSION, BSLLanguageServer settings = SolidLSPSettings() - settings.ls_specific_settings[LanguageServerId.BSL] = {"ls_path": "/custom/path/bsl-language-server.jar"} + settings.ls_specific_settings[LanguageServerId.BSL.get_key()] = {"ls_path": "/custom/path/bsl-language-server.jar"} provider = BSLLanguageServer.DependencyProvider( settings.get_ls_specific_settings(LanguageServerId.BSL), "/tmp/ls_resources", diff --git a/test/solidlsp/r/test_r_basic.py b/test/solidlsp/r/test_r_basic.py index f9d4e82a..84a9ab51 100644 --- a/test/solidlsp/r/test_r_basic.py +++ b/test/solidlsp/r/test_r_basic.py @@ -92,11 +92,6 @@ class TestRLanguageServer: assert not matcher.is_relevant_filename("script.py") assert not matcher.is_relevant_filename("README.md") - def test_r_language_enum(self): - """Test R language enum value.""" - assert LanguageServerId.R == "r" - assert str(LanguageServerId.R) == "r" - @pytest.mark.parametrize("language_server", [LanguageServerId.R], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/test_ls_registry.py b/test/solidlsp/test_ls_registry.py deleted file mode 100644 index 7d582efe..00000000 --- a/test/solidlsp/test_ls_registry.py +++ /dev/null @@ -1,406 +0,0 @@ -import logging -import tempfile -import threading -from collections.abc import Callable -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass - -import pytest - -from serena.config.serena_config import ProjectConfig -from serena.constants import PROJECT_TEMPLATE_FILE -from serena.ls_manager import LanguageServerFactory, LanguageServerManager -from solidlsp import SolidLanguageServer -from solidlsp.language_server_adapter_discovery import ( - ENTRY_POINT_GROUP, - _reset_language_server_adapter_discovery_for_tests, - discover_registered_language_server_adapters, -) -from solidlsp.ls_config import ( - FilenameMatcher, - LanguageServerConfig, - LanguageServerId, - LanguageServerKey, - RegisteredLanguageServerId, - _reset_registered_language_servers_for_tests, - register_ls, - resolve_language_server_id, -) -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo -from solidlsp.settings import SolidLSPSettings -from test.conftest import create_default_serena_config - - -@pytest.fixture(autouse=True) -def isolated_registry() -> None: - _reset_registered_language_servers_for_tests() - _reset_language_server_adapter_discovery_for_tests() - yield - _reset_registered_language_servers_for_tests() - _reset_language_server_adapter_discovery_for_tests() - - -@dataclass -class FakeDistribution: - name: str - - -class FakeEntryPoint: - def __init__(self, name: str, registration: Callable[[], None], distribution: str = "test-adapter") -> None: - self.name = name - self._registration = registration - self.dist = FakeDistribution(distribution) - - def load(self) -> Callable[[], None]: - return self._registration - - -def install_entry_points(monkeypatch: pytest.MonkeyPatch, *entry_points: FakeEntryPoint) -> None: - def fake_entry_points(*, group: str) -> tuple[FakeEntryPoint, ...]: - assert group == ENTRY_POINT_GROUP - return entry_points - - monkeypatch.setattr("solidlsp.language_server_adapter_discovery.metadata.entry_points", fake_entry_points) - - -class DummyLanguageServer(SolidLanguageServer): - def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): - super().__init__( - config, repository_root_path, ProcessLaunchInfo(cmd=["dummy"], cwd=repository_root_path), "dummy", solidlsp_settings - ) - - def _create_base_initialize_params(self) -> dict: - return {} - - def _start_server(self) -> None: - self.server.start() - - -class OtherDummyLanguageServer(DummyLanguageServer): - pass - - -def test_builtin_language_ids_remain_resolvable() -> None: - assert resolve_language_server_id("python") is LanguageServerId.PYTHON - - -def test_resolving_language_id_does_not_trigger_discovery(monkeypatch: pytest.MonkeyPatch) -> None: - def fail_if_discovered(*, group: str) -> tuple[FakeEntryPoint, ...]: - raise AssertionError(f"Discovery unexpectedly triggered for {group}") - - monkeypatch.setattr("solidlsp.language_server_adapter_discovery.metadata.entry_points", fail_if_discovered) - - assert resolve_language_server_id("python") is LanguageServerId.PYTHON - - -def test_unknown_language_id_is_rejected() -> None: - with pytest.raises(ValueError, match="Unknown language server"): - resolve_language_server_id("missing-custom-language") - - -def test_entry_point_discovery_registers_an_external_adapter(monkeypatch: pytest.MonkeyPatch) -> None: - def register_dummy() -> None: - register_ls("dummy", FilenameMatcher(".dummy"), DummyLanguageServer) - - install_entry_points(monkeypatch, FakeEntryPoint("dummy", register_dummy)) - discover_registered_language_server_adapters() - - assert resolve_language_server_id("dummy").get_ls_class() is DummyLanguageServer - - -def test_entry_point_discovery_registers_multiple_adapters(monkeypatch: pytest.MonkeyPatch) -> None: - def register_first() -> None: - register_ls("dummy-one", FilenameMatcher(".one"), DummyLanguageServer) - - def register_second() -> None: - register_ls("dummy-two", FilenameMatcher(".two"), OtherDummyLanguageServer) - - install_entry_points(monkeypatch, FakeEntryPoint("first", register_first), FakeEntryPoint("second", register_second)) - discover_registered_language_server_adapters() - - assert resolve_language_server_id("dummy-one").get_ls_class() is DummyLanguageServer - assert resolve_language_server_id("dummy-two").get_ls_class() is OtherDummyLanguageServer - - -def test_entry_point_metadata_failure_is_retryable(monkeypatch: pytest.MonkeyPatch) -> None: - calls = 0 - - def fake_entry_points(*, group: str) -> tuple[FakeEntryPoint, ...]: - nonlocal calls - calls += 1 - if calls == 1: - raise RuntimeError("metadata unavailable") - - def register_dummy() -> None: - register_ls("retryable", FilenameMatcher(".retry"), DummyLanguageServer) - - return (FakeEntryPoint("retryable", register_dummy),) - - monkeypatch.setattr("solidlsp.language_server_adapter_discovery.metadata.entry_points", fake_entry_points) - discover_registered_language_server_adapters() - discover_registered_language_server_adapters() - - assert calls == 2 - assert resolve_language_server_id("retryable").get_ls_class() is DummyLanguageServer - - -def test_concurrent_discovery_registers_each_adapter_once(monkeypatch: pytest.MonkeyPatch) -> None: - metadata_calls = 0 - registration_calls = 0 - calls_lock = threading.Lock() - second_metadata_call = threading.Event() - start_barrier = threading.Barrier(2) - - def register_concurrent() -> None: - nonlocal registration_calls - with calls_lock: - registration_calls += 1 - register_ls("concurrent", FilenameMatcher(".concurrent"), DummyLanguageServer) - - entry_point = FakeEntryPoint("concurrent", register_concurrent) - - def fake_entry_points(*, group: str) -> tuple[FakeEntryPoint, ...]: - nonlocal metadata_calls - assert group == ENTRY_POINT_GROUP - with calls_lock: - metadata_calls += 1 - call_number = metadata_calls - if call_number == 1: - second_metadata_call.wait(timeout=1) - else: - second_metadata_call.set() - return (entry_point,) - - monkeypatch.setattr("solidlsp.language_server_adapter_discovery.metadata.entry_points", fake_entry_points) - - def discover_after_barrier() -> None: - start_barrier.wait() - discover_registered_language_server_adapters() - - with ThreadPoolExecutor(max_workers=2) as executor: - futures = [executor.submit(discover_after_barrier) for _ in range(2)] - for future in futures: - future.result() - - assert metadata_calls == 1 - assert registration_calls == 1 - assert resolve_language_server_id("concurrent").get_ls_class() is DummyLanguageServer - - -def test_two_registered_language_servers_are_resolvable_together() -> None: - first = register_ls("dummy-one", FilenameMatcher(".one"), DummyLanguageServer) - second = register_ls("dummy-two", FilenameMatcher(".two"), OtherDummyLanguageServer) - - assert isinstance(first, RegisteredLanguageServerId) - assert resolve_language_server_id("dummy-one") == first - assert resolve_language_server_id("dummy-two") == second - assert first.get_source_fn_matcher().is_relevant_filename("file.one") - assert second.get_source_fn_matcher().is_relevant_filename("file.two") - - with tempfile.TemporaryDirectory() as project_data_path: - settings = SolidLSPSettings(project_data_path=project_data_path) - first_server = SolidLanguageServer.create( - LanguageServerConfig(ls_id=first), - project_data_path, - solidlsp_settings=settings, - ) - second_server = SolidLanguageServer.create( - LanguageServerConfig(ls_id=second), - project_data_path, - solidlsp_settings=settings, - ) - - assert first_server.ls_id == first - assert second_server.ls_id == second - - -def test_discovered_language_server_roundtrips_through_project_config(monkeypatch: pytest.MonkeyPatch) -> None: - def register_roundtrip() -> None: - register_ls("roundtrip", FilenameMatcher(".round"), DummyLanguageServer) - - install_entry_points(monkeypatch, FakeEntryPoint("roundtrip", register_roundtrip)) - discover_registered_language_server_adapters() - data, _ = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) - data["project_name"] = "test" - data["language_servers"] = ["roundtrip"] - - config = ProjectConfig._from_dict(data, local_override_keys=[]) - assert config.language_servers == [resolve_language_server_id("roundtrip")] - assert config._to_yaml_dict()["language_servers"] == ["roundtrip"] - - -def test_project_yml_resolves_discovered_external_adapter(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - def register_example() -> None: - register_ls("example-adapter", FilenameMatcher(".example"), DummyLanguageServer) - - install_entry_points(monkeypatch, FakeEntryPoint("example", register_example, "example-serena-adapter")) - project_config_path = tmp_path / ".serena" - project_config_path.mkdir() - (project_config_path / "project.yml").write_text('project_name: "external-example"\nlanguage_servers: ["example-adapter"]\n') - - config = ProjectConfig.load(tmp_path, create_default_serena_config()) - - assert config.language_servers == [resolve_language_server_id("example-adapter")] - - -@pytest.mark.parametrize( - ("registration_id", "message"), - [ - ("", "non-empty"), - (" ", "non-empty"), - (" leading", "trimmed"), - ("trailing ", "trimmed"), - ("Mixed-Case", "lowercase"), - ], -) -def test_registration_rejects_noncanonical_ids(registration_id: str, message: str) -> None: - with pytest.raises(ValueError, match=message): - register_ls(registration_id, FilenameMatcher(".example"), DummyLanguageServer) - - -def test_duplicate_registration_and_builtin_override_are_rejected() -> None: - register_ls("duplicate", FilenameMatcher(".dup"), DummyLanguageServer) - with pytest.raises(ValueError, match="already registered"): - register_ls("duplicate", FilenameMatcher(".dup"), DummyLanguageServer) - with pytest.raises(ValueError, match="already built in"): - register_ls("python", FilenameMatcher(".py"), DummyLanguageServer) - - -def test_implementation_class_cannot_be_registered_under_multiple_external_ids() -> None: - register_ls("first-id", FilenameMatcher(".first"), DummyLanguageServer) - - with pytest.raises(ValueError, match="DummyLanguageServer.*first-id"): - register_ls("second-id", FilenameMatcher(".second"), DummyLanguageServer) - - with pytest.raises(ValueError, match="Unknown language server"): - resolve_language_server_id("second-id") - - -def test_builtin_implementation_class_cannot_be_registered_under_external_id() -> None: - python_implementation = LanguageServerId.PYTHON.get_ls_class() - - with pytest.raises(ValueError, match="python-alias.*PyrightServer.*built-in id 'python'"): - register_ls("python-alias", FilenameMatcher(".alias"), python_implementation) - - with pytest.raises(ValueError, match="Unknown language server"): - resolve_language_server_id("python-alias") - - -def test_registry_reset_isolation() -> None: - register_ls("temporary", FilenameMatcher(".tmp"), DummyLanguageServer) - assert resolve_language_server_id("temporary").value == "temporary" - _reset_registered_language_servers_for_tests() - with pytest.raises(ValueError, match="Unknown language server"): - resolve_language_server_id("temporary") - - -def test_failing_entry_point_logs_its_distribution_and_rolls_back_registration( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - def register_broken_adapter() -> None: - register_ls("broken", FilenameMatcher(".broken"), DummyLanguageServer) - raise RuntimeError("registration failed") - - install_entry_points(monkeypatch, FakeEntryPoint("broken", register_broken_adapter, "broken-adapter")) - with caplog.at_level(logging.ERROR): - discover_registered_language_server_adapters() - - with pytest.raises(ValueError, match="Unknown language server"): - resolve_language_server_id("broken") - assert "broken" in caplog.text - assert "broken-adapter" in caplog.text - - -def test_failing_entry_point_does_not_block_other_adapters(monkeypatch: pytest.MonkeyPatch) -> None: - def register_broken_adapter() -> None: - register_ls("broken", FilenameMatcher(".broken"), DummyLanguageServer) - raise RuntimeError("registration failed") - - def register_working_adapter() -> None: - register_ls("working", FilenameMatcher(".working"), OtherDummyLanguageServer) - - install_entry_points( - monkeypatch, - FakeEntryPoint("broken", register_broken_adapter, "broken-adapter"), - FakeEntryPoint("working", register_working_adapter, "working-adapter"), - ) - discover_registered_language_server_adapters() - - with pytest.raises(ValueError, match="Unknown language server"): - resolve_language_server_id("broken") - assert resolve_language_server_id("working").get_ls_class() is OtherDummyLanguageServer - - -def test_external_entry_point_registers_its_matcher(monkeypatch: pytest.MonkeyPatch) -> None: - def register_example() -> None: - register_ls("case-insensitive-example", FilenameMatcher(".sample", ".fixture", case_sensitive=False), DummyLanguageServer) - - install_entry_points(monkeypatch, FakeEntryPoint("example", register_example, "example-serena-adapter")) - discover_registered_language_server_adapters() - - external_id = resolve_language_server_id("case-insensitive-example") - assert isinstance(external_id, RegisteredLanguageServerId) - matcher = external_id.get_source_fn_matcher() - assert matcher.is_relevant_filename("definition.SAMPLE") - assert matcher.is_relevant_filename("caller.fixture") - assert not matcher.is_relevant_filename("README.md") - - -def test_factory_and_manager_route_mixed_builtin_and_external_ids(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - external_id = register_ls("external-example", FilenameMatcher(".external"), DummyLanguageServer) - data, _ = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) - data["project_name"] = "mixed-language-servers" - data["language_servers"] = ["python", "external-example"] - data["ls_specific_settings"] = {"external-example": {"mode": "external"}} - project_config = ProjectConfig._from_dict(data, local_override_keys=[]) - captured_settings: dict[LanguageServerKey, str | None] = {} - captured_settings_lock = threading.Lock() - - class FakeLanguageServer: - def __init__(self, ls_id: LanguageServerKey) -> None: - self.ls_id = ls_id - self._running = False - - def start(self) -> None: - self._running = True - - def is_running(self) -> bool: - return self._running - - def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool) -> bool: - return not self.ls_id.get_source_fn_matcher().is_relevant_filename(relative_path) - - def fake_create(config, repository_root_path, timeout=None, solidlsp_settings=None): - assert solidlsp_settings is not None - with captured_settings_lock: - captured_settings[config.ls_id] = solidlsp_settings.get_ls_specific_settings(config.ls_id).get("mode") - return FakeLanguageServer(config.ls_id) - - class FakeProject: - project_root = str(tmp_path) - - @staticmethod - def gather_source_files() -> list[str]: - return [] - - class FakeSerenaPaths: - serena_user_home_dir = str(tmp_path / "serena-home") - - monkeypatch.setattr(SolidLanguageServer, "create", staticmethod(fake_create)) - monkeypatch.setattr("serena.ls_manager.SerenaPaths", FakeSerenaPaths) - factory = LanguageServerFactory( - project_root=str(tmp_path), - project_config=project_config, - project_data_path=str(tmp_path / "project-data"), - encoding="utf-8", - ignored_patterns=[], - ls_specific_settings=project_config.ls_specific_settings, - ) - - manager = LanguageServerManager.from_languages(project_config.language_servers, factory, FakeProject()) - - assert manager.get_active_language_server_ids() == [LanguageServerId.PYTHON, external_id] - assert manager.get_language_server("module.py").ls_id is LanguageServerId.PYTHON - assert manager.get_language_server("module.external").ls_id == external_id - assert captured_settings[LanguageServerId.PYTHON] is None - assert captured_settings[external_id] == "external"