mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 10:19:02 +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)
|
rest of the session (#1871)
|
||||||
- Fix: Exceptions raised during `LanguageServerManager.start` did not stop the language server subprocess if it was
|
- Fix: Exceptions raised during `LanguageServerManager.start` did not stop the language server subprocess if it was
|
||||||
already started (#1949)
|
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: Dart's `$/analyzerStatus` notifications were logged as unhandled-method warnings during analysis (#1855)
|
||||||
- Fix: `DartLanguageServer._start_server` discarded both `$/analyzerStatus` and
|
- Fix: `DartLanguageServer._start_server` discarded both `$/analyzerStatus` and
|
||||||
`experimental/serverStatus`, the two notifications the Dart analysis server sends to report
|
`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**
|
* **Zig**
|
||||||
(requires installation of ZLS - Zig Language Server)
|
(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,
|
Support for further languages can be added either as a built-in Serena language-server implementation or as an external Python adapter.
|
||||||
see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena/memories/adding_new_language_support_guide.md).
|
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
|
## 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 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 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)
|
* the set of built-in language servers and explicitly registered external adapters for which servers are spawned
|
||||||
Note that you can dynamically add/remove language servers while Serena is running via the [Dashboard](060_dashboard).
|
(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 [language backend](per-project-language-backend) to use for this project (overriding the global setting)
|
||||||
* the encoding used in source files
|
* the encoding used in source files
|
||||||
* ignore rules
|
* ignore rules
|
||||||
|
|||||||
+4
-4
@@ -61,7 +61,7 @@ from serena.tools import (
|
|||||||
from serena.util.gui import system_has_usable_display
|
from serena.util.gui import system_has_usable_display
|
||||||
from serena.util.inspection import iter_subclasses
|
from serena.util.inspection import iter_subclasses
|
||||||
from serena.util.logging import MemoryLogHandler
|
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 import subprocess_util
|
||||||
from solidlsp.util.subprocess_util import terminate_process_tree_with_kill_fallback
|
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()
|
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.
|
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
|
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}")
|
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.
|
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.
|
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)
|
tool_class = ToolRegistry().get_tool_class_by_name(tool_name)
|
||||||
return self.get_tool(tool_class)
|
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()
|
ls_manager = self.get_language_server_manager()
|
||||||
if ls_manager is None:
|
if ls_manager is None:
|
||||||
return []
|
return []
|
||||||
|
|||||||
+2
-2
@@ -40,7 +40,7 @@ from serena.prompt_factory import SerenaPromptFactory
|
|||||||
from serena.tools import ActivateProjectTool
|
from serena.tools import ActivateProjectTool
|
||||||
from serena.util.cli_util import AutoRegisteringGroup
|
from serena.util.cli_util import AutoRegisteringGroup
|
||||||
from serena.util.logging import MemoryLogHandler
|
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.ls_types import SymbolKind
|
||||||
from solidlsp.util.subprocess_util import subprocess_kwargs
|
from solidlsp.util.subprocess_util import subprocess_kwargs
|
||||||
|
|
||||||
@@ -813,7 +813,7 @@ class ProjectCommands(AutoRegisteringGroup):
|
|||||||
|
|
||||||
collected_exceptions: list[Exception] = []
|
collected_exceptions: list[Exception] = []
|
||||||
files_failed = []
|
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()
|
last_save_time = time.monotonic()
|
||||||
for i, f in enumerate(tqdm(files, desc="Indexing")):
|
for i, f in enumerate(tqdm(files, desc="Indexing")):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from serena.constants import (
|
|||||||
from serena.util.inspection import compute_language_server_support_composition
|
from serena.util.inspection import compute_language_server_support_composition
|
||||||
from serena.util.text_utils import GlobMatcher
|
from serena.util.text_utils import GlobMatcher
|
||||||
from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml_comments, save_yaml, transfer_yaml_comments
|
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 ..analytics import RegisteredTokenCountEstimator
|
||||||
from ..util.class_decorators import singleton
|
from ..util.class_decorators import singleton
|
||||||
@@ -321,7 +321,7 @@ class ProjectConfigAutoGenerationMode(Enum):
|
|||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
|
class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
|
||||||
project_name: str
|
project_name: str
|
||||||
language_servers: list[LanguageServerId]
|
language_servers: list[LanguageServerKey]
|
||||||
ignored_paths: list[str] = field(default_factory=list)
|
ignored_paths: list[str] = field(default_factory=list)
|
||||||
ls_workspace_folders: list[str] = field(default_factory=lambda: ["."])
|
ls_workspace_folders: list[str] = field(default_factory=lambda: ["."])
|
||||||
ls_additional_workspace_folders: list[str] = field(default_factory=list)
|
ls_additional_workspace_folders: list[str] = field(default_factory=list)
|
||||||
@@ -358,7 +358,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _determine_project_language_servers(
|
def _determine_project_language_servers(
|
||||||
cls, project_root: str, interactive: bool, serena_config: "SerenaConfig"
|
cls, project_root: str, interactive: bool, serena_config: "SerenaConfig"
|
||||||
) -> list[LanguageServerId]:
|
) -> list[LanguageServerKey]:
|
||||||
log.info("Determining suitable language servers for the project")
|
log.info("Determining suitable language servers for the project")
|
||||||
|
|
||||||
# determine language servers to be considered and their priorities
|
# 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
|
# map languages to list of enum items, checking for errors
|
||||||
lang_name_mapping = {"javascript": "typescript"}
|
lang_name_mapping = {"javascript": "typescript"}
|
||||||
ls_ids: list[LanguageServerId] = []
|
ls_ids: list[LanguageServerKey] = []
|
||||||
for ls_str in data["language_servers"]:
|
for ls_str in data["language_servers"]:
|
||||||
orig_language_str = ls_str
|
orig_language_str = ls_str
|
||||||
try:
|
try:
|
||||||
ls_str = ls_str.lower()
|
ls_str = ls_str.lower()
|
||||||
if ls_str in lang_name_mapping:
|
if ls_str in lang_name_mapping:
|
||||||
ls_str = lang_name_mapping[ls_str]
|
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)
|
ls_ids.append(ls_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ValueError(
|
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
|
) from e
|
||||||
|
|
||||||
# Validate activation_command_timeout
|
# Validate activation_command_timeout
|
||||||
@@ -693,6 +694,12 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
|
|||||||
:param serena_config: the global Serena configuration
|
:param serena_config: the global Serena configuration
|
||||||
:param autogen: the auto-generation mode to apply if the project configuration does not yet exist
|
: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_root = Path(project_root)
|
||||||
project_folder_name = project_root.name
|
project_folder_name = project_root.name
|
||||||
yaml_path = serena_config.get_project_yml_location(project_root)
|
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()
|
self._current_config_overview = self._compute_config_overview().model_dump()
|
||||||
|
|
||||||
def _get_available_languages(self) -> ResponseAvailableLanguages:
|
def _get_available_languages(self) -> ResponseAvailableLanguages:
|
||||||
from solidlsp.ls_config import LanguageServerId
|
from solidlsp.ls_config import LanguageServerId, registered_language_servers
|
||||||
|
|
||||||
def run() -> ResponseAvailableLanguages:
|
def run() -> ResponseAvailableLanguages:
|
||||||
all_languages = [lang.value for lang in LanguageServerId.iter_all(include_experimental=True)]
|
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
|
# Filter out already added languages for the active project
|
||||||
project = self._agent.get_active_project()
|
project = self._agent.get_active_project()
|
||||||
@@ -775,20 +776,20 @@ class SerenaDashboardAPI:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _add_language(self, request_add_language: RequestAddLanguage) -> None:
|
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:
|
try:
|
||||||
language = LanguageServerId(request_add_language.language)
|
language = resolve_language_server_id(request_add_language.language)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"Invalid language server identifier: {request_add_language.language}")
|
raise ValueError(f"Invalid language server identifier: {request_add_language.language}")
|
||||||
# add_language is already thread-safe
|
# add_language is already thread-safe
|
||||||
self._agent.add_language_server(language)
|
self._agent.add_language_server(language)
|
||||||
|
|
||||||
def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None:
|
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:
|
try:
|
||||||
language = LanguageServerId(request_remove_language.language)
|
language = resolve_language_server_id(request_remove_language.language)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"Invalid language server identifier: {request_remove_language.language}")
|
raise ValueError(f"Invalid language server identifier: {request_remove_language.language}")
|
||||||
# remove_language is already thread-safe
|
# 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 serena.config.serena_config import ProjectConfig, SerenaPaths
|
||||||
from solidlsp import SolidLanguageServer
|
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.lsp_protocol_handler.lsp_types import DidChangeWatchedFilesParams, FileChangeType, FileEvent
|
||||||
from solidlsp.settings import SolidLSPSettings
|
from solidlsp.settings import SolidLSPSettings
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class LanguageServerFactory:
|
|||||||
self.ls_specific_settings = ls_specific_settings
|
self.ls_specific_settings = ls_specific_settings
|
||||||
self.trace_lsp_communication = trace_lsp_communication
|
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(
|
ls_config = LanguageServerConfig(
|
||||||
workspace_folders=self.project_config.ls_workspace_folders,
|
workspace_folders=self.project_config.ls_workspace_folders,
|
||||||
additional_workspace_folders=self.project_config.ls_additional_workspace_folders,
|
additional_workspace_folders=self.project_config.ls_additional_workspace_folders,
|
||||||
@@ -75,7 +75,7 @@ class LanguageServerManager:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
language_servers: dict[LanguageServerId, SolidLanguageServer],
|
language_servers: dict[LanguageServerKey, SolidLanguageServer],
|
||||||
language_server_factory: LanguageServerFactory,
|
language_server_factory: LanguageServerFactory,
|
||||||
project: "Project",
|
project: "Project",
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -97,7 +97,7 @@ class LanguageServerManager:
|
|||||||
return next(iter(self._language_servers.values()))
|
return next(iter(self._language_servers.values()))
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Creates a manager with language servers for the given languages using the given factory.
|
||||||
The language servers are started in parallel threads.
|
The language servers are started in parallel threads.
|
||||||
@@ -109,7 +109,7 @@ class LanguageServerManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class StartLSThread(threading.Thread):
|
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)
|
super().__init__(target=self._start_language_server, name="StartLS:" + ls_id.value)
|
||||||
self.ls_id = ls_id
|
self.ls_id = ls_id
|
||||||
self.language_server: SolidLanguageServer | None = None
|
self.language_server: SolidLanguageServer | None = None
|
||||||
@@ -134,8 +134,8 @@ class LanguageServerManager:
|
|||||||
threads.append(thread)
|
threads.append(thread)
|
||||||
|
|
||||||
# collect language servers and exceptions
|
# collect language servers and exceptions
|
||||||
language_servers: dict[LanguageServerId, SolidLanguageServer] = {}
|
language_servers: dict[LanguageServerKey, SolidLanguageServer] = {}
|
||||||
exceptions: dict[LanguageServerId, Exception] = {}
|
exceptions: dict[LanguageServerKey, Exception] = {}
|
||||||
for thread in threads:
|
for thread in threads:
|
||||||
thread.join()
|
thread.join()
|
||||||
if thread.exception is not None:
|
if thread.exception is not None:
|
||||||
@@ -183,7 +183,7 @@ class LanguageServerManager:
|
|||||||
ls = self._default_language_server
|
ls = self._default_language_server
|
||||||
return self._ensure_functional_ls(ls)
|
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:
|
if self._language_server_factory is None:
|
||||||
raise ValueError(f"No language server factory available to create language server for {ls_id}")
|
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)
|
language_server = self._language_server_factory.create_language_server(ls_id)
|
||||||
@@ -191,7 +191,7 @@ class LanguageServerManager:
|
|||||||
self._language_servers[ls_id] = language_server
|
self._language_servers[ls_id] = language_server
|
||||||
return 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.
|
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.
|
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")
|
raise ValueError(f"No language server for language {language.value} present; cannot restart")
|
||||||
return self._create_and_start_language_server(language)
|
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.
|
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")
|
raise ValueError(f"Language server for language {ls_id.value} already present")
|
||||||
return self._create_and_start_language_server(ls_id)
|
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.
|
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)
|
ls = self._language_servers.pop(language)
|
||||||
self._stop_language_server(ls, save_cache=save_cache)
|
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.
|
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.file_system import GitignoreParser, match_path, scan_directory
|
||||||
from serena.util.text_utils import MatchedConsecutiveLines, search_files
|
from serena.util.text_utils import MatchedConsecutiveLines, search_files
|
||||||
from solidlsp import SolidLanguageServer
|
from solidlsp import SolidLanguageServer
|
||||||
from solidlsp.ls_config import LanguageServerId
|
from solidlsp.ls_config import LanguageServerKey
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from serena.agent import SerenaAgent
|
from serena.agent import SerenaAgent
|
||||||
@@ -567,7 +567,7 @@ class Project(ToStringMixin):
|
|||||||
raise Exception(msg.build())
|
raise Exception(msg.build())
|
||||||
return self.language_server_manager
|
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
|
Adds a new language server to the project configuration, starting the corresponding
|
||||||
server instance if the LS manager is active.
|
server instance if the LS manager is active.
|
||||||
@@ -590,7 +590,7 @@ class Project(ToStringMixin):
|
|||||||
self.project_config.language_servers.append(ls_id)
|
self.project_config.language_servers.append(ls_id)
|
||||||
self.save_config()
|
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
|
Removes a language server from the project configuration, stopping the corresponding
|
||||||
server instance if the LS manager is active.
|
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 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 SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||||
# - For Free Pascal/Lazarus, use pascal
|
# - 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:
|
# Special requirements:
|
||||||
# Some language servers require additional setup/installations.
|
# Some language servers require additional setup/installations.
|
||||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
# 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,
|
LanguageServerDependencyProviderUvx,
|
||||||
)
|
)
|
||||||
from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder
|
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_exceptions import InvalidTextLocationError, SolidLSPException
|
||||||
from solidlsp.ls_process import DEFAULT_LS_REQUEST_TIMEOUT, LanguageServerInterface, StdioLanguageServer
|
from solidlsp.ls_process import DEFAULT_LS_REQUEST_TIMEOUT, LanguageServerInterface, StdioLanguageServer
|
||||||
from solidlsp.ls_types import UnifiedSymbolInformation
|
from solidlsp.ls_types import UnifiedSymbolInformation
|
||||||
@@ -416,7 +416,7 @@ class SolidLanguageServer(ABC):
|
|||||||
return logging.INFO
|
return logging.INFO
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_language_server_id(cls) -> LanguageServerId:
|
def get_language_server_id(cls) -> LanguageServerKey:
|
||||||
return LanguageServerId.from_ls_class(cls)
|
return LanguageServerId.from_ls_class(cls)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+125
-2
@@ -2,6 +2,8 @@
|
|||||||
Configuration objects for language servers
|
Configuration objects for language servers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -87,6 +89,121 @@ class FilenameMatcher:
|
|||||||
return False
|
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):
|
class LanguageServerId(str, Enum):
|
||||||
"""
|
"""
|
||||||
Enumeration of language servers supported by SolidLSP.
|
Enumeration of language servers supported by SolidLSP.
|
||||||
@@ -939,7 +1056,7 @@ class LanguageServerId(str, Enum):
|
|||||||
raise ValueError(f"Unhandled language: {self}")
|
raise ValueError(f"Unhandled language: {self}")
|
||||||
|
|
||||||
@classmethod
|
@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.
|
Get the Language enum value from a SolidLanguageServer class.
|
||||||
|
|
||||||
@@ -950,16 +1067,22 @@ class LanguageServerId(str, Enum):
|
|||||||
for enum_instance in cls:
|
for enum_instance in cls:
|
||||||
if enum_instance.get_ls_class() == ls_class:
|
if enum_instance.get_ls_class() == ls_class:
|
||||||
return enum_instance
|
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}")
|
raise ValueError(f"Unhandled language server class: {ls_class}")
|
||||||
|
|
||||||
|
|
||||||
|
LanguageServerKey = LanguageServerId | RegisteredLanguageServerId
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LanguageServerConfig:
|
class LanguageServerConfig:
|
||||||
"""
|
"""
|
||||||
Configuration parameters for a language server instance
|
Configuration parameters for a language server instance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ls_id: LanguageServerId
|
ls_id: LanguageServerKey
|
||||||
"""
|
"""
|
||||||
defines the language server to use
|
defines the language server to use
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
This module contains the exceptions raised by the framework.
|
This module contains the exceptions raised by the framework.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from solidlsp.ls_config import LanguageServerId
|
from solidlsp.ls_config import LanguageServerKey
|
||||||
|
|
||||||
|
|
||||||
class SolidLSPException(Exception):
|
class SolidLSPException(Exception):
|
||||||
@@ -28,7 +28,7 @@ class SolidLSPException(Exception):
|
|||||||
|
|
||||||
return isinstance(self.cause, LanguageServerTerminatedException)
|
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
|
: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 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_exceptions import SolidLSPException
|
||||||
from solidlsp.ls_request import LanguageServerRequest
|
from solidlsp.ls_request import LanguageServerRequest
|
||||||
from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification
|
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.
|
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)
|
super().__init__(message)
|
||||||
self.message = message
|
self.message = message
|
||||||
self.ls_id = ls_id
|
self.ls_id = ls_id
|
||||||
@@ -121,7 +121,7 @@ class LanguageServerInterface(ABC):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
ls_id: LanguageServerId,
|
ls_id: LanguageServerKey,
|
||||||
determine_log_level: Callable[[str], int],
|
determine_log_level: Callable[[str], int],
|
||||||
logger: Callable[[str, str, StringDict | str], None] | None = None,
|
logger: Callable[[str, str, StringDict | str], None] | None = None,
|
||||||
request_timeout: float | None = None,
|
request_timeout: float | None = None,
|
||||||
@@ -491,7 +491,7 @@ class StdioLanguageServer(LanguageServerInterface):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
process_launch_info: ProcessLaunchInfo,
|
process_launch_info: ProcessLaunchInfo,
|
||||||
ls_id: LanguageServerId,
|
ls_id: LanguageServerKey,
|
||||||
determine_log_level: Callable[[str], int],
|
determine_log_level: Callable[[str], int],
|
||||||
logger: Callable[[str, str, StringDict | str], None] | None = None,
|
logger: Callable[[str, str, StringDict | str], None] | None = None,
|
||||||
start_independent_lsp_process: bool = True,
|
start_independent_lsp_process: bool = True,
|
||||||
@@ -689,7 +689,7 @@ class TCPLanguageServer(LanguageServerInterface):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
connection_info: TCPConnectionInfo,
|
connection_info: TCPConnectionInfo,
|
||||||
ls_id: LanguageServerId,
|
ls_id: LanguageServerKey,
|
||||||
determine_log_level: Callable[[str], int],
|
determine_log_level: Callable[[str], int],
|
||||||
logger: Callable[[str, str, StringDict | str], None] | None = None,
|
logger: Callable[[str, str, StringDict | str], None] | None = None,
|
||||||
request_timeout: float | None = None,
|
request_timeout: float | None = None,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from sensai.util.string import ToStringMixin
|
from sensai.util.string import ToStringMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from solidlsp.ls_config import LanguageServerId
|
from solidlsp.ls_config import LanguageServerKey
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ class SolidLSPSettings:
|
|||||||
For instance, if this is "/home/user/myproject/.solidlsp",
|
For instance, if this is "/home/user/myproject/.solidlsp",
|
||||||
then Solid-LSP will store project-specific data (e.g. caches) in that directory.
|
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.
|
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.
|
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
|
value = default_value
|
||||||
return 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
|
Gets the custom settings for the given language server
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,48 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from types import SimpleNamespace
|
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 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:
|
class _DummyMemoryLogHandler:
|
||||||
@@ -16,6 +56,12 @@ class _DummyMemoryLogHandler:
|
|||||||
class _DummyAgent:
|
class _DummyAgent:
|
||||||
def __init__(self, project: SimpleNamespace | None) -> None:
|
def __init__(self, project: SimpleNamespace | None) -> None:
|
||||||
self._project = project
|
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:
|
def register_config_changed_callback(self, callback: Callable[[], None]) -> None:
|
||||||
pass
|
pass
|
||||||
@@ -27,13 +73,44 @@ class _DummyAgent:
|
|||||||
def get_active_project(self):
|
def get_active_project(self):
|
||||||
return self._project
|
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
|
project = None
|
||||||
if project_languages is not 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)
|
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():
|
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
|
assert LanguageServerId.MARKDOWN.value not in available
|
||||||
# ensure experimental languages remain available for selection
|
# ensure experimental languages remain available for selection
|
||||||
assert LanguageServerId.ANSIBLE.value in available
|
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