mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-11 12:29:04 +00:00
feat(solidlsp): prototype registrable language server adapters
This commit is contained in:
1 parent
13ac8c5b1d
commit
6000f16680
19 files changed
+892
-54
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user