feat(solidlsp): prototype registrable language server adapters

This commit is contained in:
Vitaly Ruhl authored and Dominik Jain committed 2026-09-05 12:36:53 +02:00
1 parent 13ac8c5b1d
commit 6000f16680
19 files changed
+892 -54

No files matched your search

+2
View File
@@ -45,6 +45,8 @@ Status of the `main` branch. Changes prior to the next official version change w
rest of the session (#1871)
- Fix: Exceptions raised during `LanguageServerManager.start` did not stop the language server subprocess if it was
already started (#1949)
- Add: Installed Python packages can provide generic external language-server adapters through the
`serena.language_servers` entry-point group for explicit use in `project.yml`
- Fix: Dart's `$/analyzerStatus` notifications were logged as unhandled-method warnings during analysis (#1855)
- Fix: `DartLanguageServer._start_server` discarded both `$/analyzerStatus` and
`experimental/serverStatus`, the two notifications the Dart analysis server sends to report
+5 -2
View File
@@ -190,8 +190,11 @@ Some languages require additional installations or setup steps, as noted.
* **Zig**
(requires installation of ZLS - Zig Language Server)
Support for further languages can easily be added by providing a shallow adapter for a new language server implementation,
see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena/memories/adding_new_language_support_guide.md).
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).
## The Serena JetBrains Plugin
@@ -0,0 +1,55 @@
# 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.
+5 -2
View File
@@ -45,8 +45,11 @@ within the project directory.
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 programming languages for which language servers are spawned (not relevant when using the JetBrains plugin)
Note that you can dynamically add/remove language servers while Serena is running via the [Dashboard](060_dashboard).
* 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).
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)
* the encoding used in source files
* ignore rules
+4 -4
View File
@@ -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 LanguageServerId
from solidlsp.ls_config import LanguageServerKey
from solidlsp.util import subprocess_util
from solidlsp.util.subprocess_util import terminate_process_tree_with_kill_fallback
@@ -1436,7 +1436,7 @@ class SerenaAgent:
"""
self.get_active_project_or_raise().create_language_server_manager()
def add_language_server(self, ls_id: LanguageServerId) -> None:
def add_language_server(self, ls_id: LanguageServerKey) -> 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
@@ -1446,7 +1446,7 @@ class SerenaAgent:
"""
self.execute_task(lambda: self.get_active_project_or_raise().add_language_server(ls_id), name=f"AddLanguage:{ls_id.value}")
def remove_language_server(self, ls_id: LanguageServerId) -> None:
def remove_language_server(self, ls_id: LanguageServerKey) -> 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.
@@ -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[LanguageServerId]:
def get_active_language_server_ids(self) -> list[LanguageServerKey]:
ls_manager = self.get_language_server_manager()
if ls_manager is None:
return []
+2 -2
View File
@@ -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
from solidlsp.ls_config import LanguageServerId, LanguageServerKey
from solidlsp.ls_types import SymbolKind
from solidlsp.util.subprocess_util import subprocess_kwargs
@@ -813,7 +813,7 @@ class ProjectCommands(AutoRegisteringGroup):
collected_exceptions: list[Exception] = []
files_failed = []
language_file_counts: dict[LanguageServerId, int] = collections.defaultdict(lambda: 0)
language_file_counts: dict[LanguageServerKey, int] = collections.defaultdict(lambda: 0)
last_save_time = time.monotonic()
for i, f in enumerate(tqdm(files, desc="Indexing")):
try:
+13 -6
View File
@@ -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
from solidlsp.ls_config import LanguageServerId, LanguageServerKey, registered_language_servers, resolve_language_server_id
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[LanguageServerId]
language_servers: list[LanguageServerKey]
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[LanguageServerId]:
) -> list[LanguageServerKey]:
log.info("Determining suitable language servers for the project")
# determine language servers to be considered and their priorities
@@ -580,18 +580,19 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
"""
# map languages to list of enum items, checking for errors
lang_name_mapping = {"javascript": "typescript"}
ls_ids: list[LanguageServerId] = []
ls_ids: list[LanguageServerKey] = []
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 = LanguageServerId(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: {[l.value for l in LanguageServerId]}"
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
# Validate activation_command_timeout
@@ -693,6 +694,12 @@ 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)
+6 -5
View File
@@ -637,10 +637,11 @@ class SerenaDashboardAPI:
self._current_config_overview = self._compute_config_overview().model_dump()
def _get_available_languages(self) -> ResponseAvailableLanguages:
from solidlsp.ls_config import LanguageServerId
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())
# Filter out already added languages for the active project
project = self._agent.get_active_project()
@@ -775,20 +776,20 @@ class SerenaDashboardAPI:
return {}
def _add_language(self, request_add_language: RequestAddLanguage) -> None:
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import resolve_language_server_id
try:
language = LanguageServerId(request_add_language.language)
language = resolve_language_server_id(request_add_language.language)
except ValueError:
raise ValueError(f"Invalid language server identifier: {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 LanguageServerId
from solidlsp.ls_config import resolve_language_server_id
try:
language = LanguageServerId(request_remove_language.language)
language = resolve_language_server_id(request_remove_language.language)
except ValueError:
raise ValueError(f"Invalid language server identifier: {request_remove_language.language}")
# remove_language is already thread-safe
+12 -12
View File
@@ -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, LanguageServerId
from solidlsp.ls_config import LanguageServerConfig, LanguageServerKey
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: LanguageServerId) -> SolidLanguageServer:
def create_language_server(self, ls_id: LanguageServerKey) -> 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[LanguageServerId, SolidLanguageServer],
language_servers: dict[LanguageServerKey, SolidLanguageServer],
language_server_factory: LanguageServerFactory,
project: "Project",
) -> None:
@@ -97,7 +97,7 @@ class LanguageServerManager:
return next(iter(self._language_servers.values()))
@staticmethod
def from_languages(languages: list[LanguageServerId], factory: LanguageServerFactory, project: "Project") -> "LanguageServerManager":
def from_languages(languages: list[LanguageServerKey], 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,7 +109,7 @@ class LanguageServerManager:
"""
class StartLSThread(threading.Thread):
def __init__(self, ls_id: LanguageServerId):
def __init__(self, ls_id: LanguageServerKey):
super().__init__(target=self._start_language_server, name="StartLS:" + ls_id.value)
self.ls_id = ls_id
self.language_server: SolidLanguageServer | None = None
@@ -134,8 +134,8 @@ class LanguageServerManager:
threads.append(thread)
# collect language servers and exceptions
language_servers: dict[LanguageServerId, SolidLanguageServer] = {}
exceptions: dict[LanguageServerId, Exception] = {}
language_servers: dict[LanguageServerKey, SolidLanguageServer] = {}
exceptions: dict[LanguageServerKey, Exception] = {}
for thread in threads:
thread.join()
if thread.exception is not None:
@@ -183,7 +183,7 @@ class LanguageServerManager:
ls = self._default_language_server
return self._ensure_functional_ls(ls)
def _create_and_start_language_server(self, ls_id: LanguageServerId) -> SolidLanguageServer:
def _create_and_start_language_server(self, ls_id: LanguageServerKey) -> 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,7 +191,7 @@ class LanguageServerManager:
self._language_servers[ls_id] = language_server
return language_server
def restart_language_server(self, language: LanguageServerId) -> SolidLanguageServer:
def restart_language_server(self, language: LanguageServerKey) -> 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.
@@ -203,7 +203,7 @@ class LanguageServerManager:
raise ValueError(f"No language server for language {language.value} present; cannot restart")
return self._create_and_start_language_server(language)
def add_language_server(self, ls_id: LanguageServerId) -> SolidLanguageServer:
def add_language_server(self, ls_id: LanguageServerKey) -> SolidLanguageServer:
"""
Dynamically adds a new language server for the given language.
@@ -214,7 +214,7 @@ class LanguageServerManager:
raise ValueError(f"Language server for language {ls_id.value} already present")
return self._create_and_start_language_server(ls_id)
def remove_language_server(self, language: LanguageServerId, save_cache: bool = False) -> None:
def remove_language_server(self, language: LanguageServerKey, save_cache: bool = False) -> None:
"""
Removes the language server for the given language, stopping it if it is running.
@@ -225,7 +225,7 @@ class LanguageServerManager:
ls = self._language_servers.pop(language)
self._stop_language_server(ls, save_cache=save_cache)
def get_active_language_server_ids(self) -> list[LanguageServerId]:
def get_active_language_server_ids(self) -> list[LanguageServerKey]:
"""
Returns the list of languages for which language servers are currently managed.
+3 -3
View File
@@ -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 LanguageServerId
from solidlsp.ls_config import LanguageServerKey
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: LanguageServerId) -> None:
def add_language_server(self, ls_id: LanguageServerKey) -> None:
"""
Adds a new language server to the project configuration, starting the corresponding
server instance if the LS manager is active.
@@ -590,7 +590,7 @@ class Project(ToStringMixin):
self.project_config.language_servers.append(ls_id)
self.save_config()
def remove_language_server(self, ls_id: LanguageServerId) -> None:
def remove_language_server(self, ls_id: LanguageServerKey) -> None:
"""
Removes a language server from the project configuration, stopping the corresponding
server instance if the LS manager is active.
@@ -29,6 +29,8 @@ project_name: "project_name"
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# - External Python adapters may add further registered IDs; install the adapter package first
# and then use its ID here, for example: example
# Special requirements:
# Some language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
@@ -0,0 +1,72 @@
"""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
+2 -2
View File
@@ -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
from solidlsp.ls_config import FilenameMatcher, LanguageServerConfig, LanguageServerId, LanguageServerKey
from solidlsp.ls_exceptions import InvalidTextLocationError, SolidLSPException
from solidlsp.ls_process import DEFAULT_LS_REQUEST_TIMEOUT, LanguageServerInterface, StdioLanguageServer
from solidlsp.ls_types import UnifiedSymbolInformation
@@ -416,7 +416,7 @@ class SolidLanguageServer(ABC):
return logging.INFO
@classmethod
def get_language_server_id(cls) -> LanguageServerId:
def get_language_server_id(cls) -> LanguageServerKey:
return LanguageServerId.from_ls_class(cls)
@classmethod
+125 -2
View File
@@ -2,6 +2,8 @@
Configuration objects for language servers
"""
from __future__ import annotations
import logging
import os
import re
@@ -87,6 +89,121 @@ 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.
"""
_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_source_fn_matcher(self) -> FilenameMatcher:
return self._matcher
def get_ls_class(self) -> type["SolidLanguageServer"]:
return self._implementation
def get_priority(self) -> int:
return 0
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.
"""
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.
@@ -939,7 +1056,7 @@ class LanguageServerId(str, Enum):
raise ValueError(f"Unhandled language: {self}")
@classmethod
def from_ls_class(cls, ls_class: type["SolidLanguageServer"]) -> Self:
def from_ls_class(cls, ls_class: type["SolidLanguageServer"]) -> LanguageServerKey:
"""
Get the Language enum value from a SolidLanguageServer class.
@@ -950,16 +1067,22 @@ class LanguageServerId(str, Enum):
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}")
LanguageServerKey = LanguageServerId | RegisteredLanguageServerId
@dataclass(frozen=True)
class LanguageServerConfig:
"""
Configuration parameters for a language server instance
"""
ls_id: LanguageServerId
ls_id: LanguageServerKey
"""
defines the language server to use
"""
+2 -2
View File
@@ -2,7 +2,7 @@
This module contains the exceptions raised by the framework.
"""
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import LanguageServerKey
class SolidLSPException(Exception):
@@ -28,7 +28,7 @@ class SolidLSPException(Exception):
return isinstance(self.cause, LanguageServerTerminatedException)
def get_affected_language(self) -> LanguageServerId | None:
def get_affected_language(self) -> LanguageServerKey | None:
"""
:return: the affected language for the case where the exception is caused by the language server having terminated
"""
+5 -5
View File
@@ -12,7 +12,7 @@ from typing import IO, Any, AnyStr
from sensai.util.string import ToStringMixin
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import LanguageServerKey
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: LanguageServerId, cause: Exception | None = None) -> None:
def __init__(self, message: str, ls_id: LanguageServerKey, 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: LanguageServerId,
ls_id: LanguageServerKey,
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: LanguageServerId,
ls_id: LanguageServerKey,
determine_log_level: Callable[[str], int],
logger: Callable[[str, str, StringDict | str], None] | None = None,
start_independent_lsp_process: bool = True,
@@ -689,7 +689,7 @@ class TCPLanguageServer(LanguageServerInterface):
def __init__(
self,
connection_info: TCPConnectionInfo,
ls_id: LanguageServerId,
ls_id: LanguageServerKey,
determine_log_level: Callable[[str], int],
logger: Callable[[str, str, StringDict | str], None] | None = None,
request_timeout: float | None = None,
+3 -3
View File
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any
from sensai.util.string import ToStringMixin
if TYPE_CHECKING:
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import LanguageServerKey
log = logging.getLogger(__name__)
@@ -37,7 +37,7 @@ 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["LanguageServerId", dict[str, Any]] = field(default_factory=dict)
ls_specific_settings: dict["LanguageServerKey", 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.
@@ -76,7 +76,7 @@ class SolidLSPSettings:
value = default_value
return value
def get_ls_specific_settings(self, ls_id: "LanguageServerId") -> CustomLSSettings:
def get_ls_specific_settings(self, ls_id: "LanguageServerKey") -> CustomLSSettings:
"""
Gets the custom settings for the given language server
+168 -4
View File
@@ -1,8 +1,48 @@
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.ls_config import LanguageServerId
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)
class _DummyMemoryLogHandler:
@@ -16,6 +56,12 @@ 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
@@ -27,13 +73,44 @@ class _DummyAgent:
def get_active_project(self):
return self._project
def get_context(self):
return self._context
def _make_dashboard(project_languages: list[LanguageServerId] | None) -> SerenaDashboardAPI:
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:
project = None
if project_languages is not None:
project = SimpleNamespace(project_config=SimpleNamespace(language_servers=project_languages))
project = SimpleNamespace(
project_name="test-project",
project_root="/test/project",
project_config=SimpleNamespace(language_servers=project_languages, encoding="utf-8"),
)
agent = _DummyAgent(project)
return SerenaDashboardAPI(memory_log_handler=_DummyMemoryLogHandler(), tool_names=[], agent=agent, tool_usage_stats=None)
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
def test_available_languages_include_experimental_when_no_active_project():
@@ -51,3 +128,90 @@ 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
+406
View File
@@ -0,0 +1,406 @@
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"