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