Merge pull request #1988 from oraios/ls-registry

SolidLSP: Add language server registry, supporting externally provided implementations
This commit is contained in:
Dr. Dominik Jain authored and GitHub committed 2026-09-12 23:16:52 +02:00
commit 403ad0a562
24 files changed
+323 -162

No files matched your search

+2
View File
@@ -56,6 +56,8 @@ Status of the `main` branch. Changes prior to the next official version change w
rest of the session (#1871)
- Fix: Exceptions raised during `LanguageServerManager.start` did not stop the language server subprocess if it was
already started (#1949)
- Add: Installed Python packages can provide generic external language-server adapters through the
`serena.language_servers` entry-point group for explicit use in `project.yml`
- Fix: Dart's `$/analyzerStatus` notifications were logged as unhandled-method warnings during analysis (#1855)
- Fix: `DartLanguageServer._start_server` discarded both `$/analyzerStatus` and
`experimental/serverStatus`, the two notifications the Dart analysis server sends to report
+3 -2
View File
@@ -190,8 +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 easily be added by providing a shallow adapter for a new language server implementation,
see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena/memories/adding_new_language_support_guide.md).
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
+5 -2
View File
@@ -45,8 +45,11 @@ within the project directory.
The file allows you to configure ...
* the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project)
* the set of programming languages for which language servers are spawned (not relevant when using the JetBrains plugin)
Note that you can dynamically add/remove language servers while Serena is running via the [Dashboard](060_dashboard).
* the set of built-in language servers and explicitly registered external adapters for which servers are spawned
(not relevant when using the JetBrains plugin). External adapter IDs become available after their Python package is installed;
see [External Language Server Adapters](../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)
* the encoding used in source files
* ignore rules
+2 -1
View File
@@ -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
View File
@@ -61,7 +61,7 @@ from serena.tools import (
from serena.util.gui import system_has_usable_display
from serena.util.inspection import iter_subclasses
from serena.util.logging import MemoryLogHandler
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import 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: LanguageServerId) -> 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: LanguageServerId) -> 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[LanguageServerId]:
def get_active_language_server_ids(self) -> list[LanguageServerIdLike]:
ls_manager = self.get_language_server_manager()
if ls_manager is None:
return []
+7 -5
View File
@@ -40,7 +40,7 @@ from serena.prompt_factory import SerenaPromptFactory
from serena.tools import ActivateProjectTool
from serena.util.cli_util import AutoRegisteringGroup
from serena.util.logging import MemoryLogHandler
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import LanguageServerId, 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[LanguageServerId, 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:
+12 -17
View File
@@ -35,7 +35,7 @@ from serena.constants import (
from serena.util.inspection import compute_language_server_support_composition
from serena.util.text_utils import GlobMatcher
from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml_comments, save_yaml, transfer_yaml_comments
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import LanguageServerId, 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[LanguageServerId]
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[LanguageServerId]:
) -> 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,19 +580,14 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
"""
# map languages to list of enum items, checking for errors
lang_name_mapping = {"javascript": "typescript"}
ls_ids: list[LanguageServerId] = []
ls_ids: list[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 = LanguageServerId(ls_str)
ls_ids.append(ls_id)
except ValueError as e:
raise ValueError(
f"Invalid language server: '{orig_language_str}'.\nValid values are: {[l.value for l in LanguageServerId]}"
) 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)
@@ -669,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
+9 -20
View File
@@ -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,15 +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
def run() -> ResponseAvailableLanguages:
all_languages = [lang.value for lang in LanguageServerId.iter_all(include_experimental=True)]
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
@@ -775,22 +774,12 @@ class SerenaDashboardAPI:
return {}
def _add_language(self, request_add_language: RequestAddLanguage) -> None:
from solidlsp.ls_config import LanguageServerId
try:
language = LanguageServerId(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 LanguageServerId
try:
language = LanguageServerId(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
View File
@@ -9,7 +9,7 @@ from sensai.util.logging import LogTime
from serena.config.serena_config import ProjectConfig, SerenaPaths
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.ls_config import LanguageServerConfig, 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: LanguageServerId) -> 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[LanguageServerId, 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[LanguageServerId], 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: LanguageServerId):
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[LanguageServerId, SolidLanguageServer] = {}
exceptions: dict[LanguageServerId, 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: LanguageServerId) -> 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: LanguageServerId) -> 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: LanguageServerId) -> 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: LanguageServerId, 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[LanguageServerId]:
def get_active_language_server_ids(self) -> list[LanguageServerIdLike]:
"""
Returns the list of languages for which language servers are currently managed.
+7 -7
View File
@@ -21,7 +21,7 @@ from serena.util.file_proxy import FileCollection, FileProxy
from serena.util.file_system import GitignoreParser, match_path, scan_directory
from serena.util.text_utils import MatchedConsecutiveLines, search_files
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import 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: LanguageServerId) -> 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: LanguageServerId) -> 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:
@@ -29,6 +29,8 @@ project_name: "project_name"
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# - External Python adapters may add further registered IDs; install the adapter package first
# and then use its ID here, for example: example
# Special requirements:
# Some language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
@@ -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
View File
@@ -31,7 +31,7 @@ from solidlsp.dependency_provider import (
LanguageServerDependencyProviderUvx,
)
from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder
from solidlsp.ls_config import FilenameMatcher, LanguageServerConfig, LanguageServerId
from solidlsp.ls_config import FilenameMatcher, LanguageServerConfig
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) -> LanguageServerId:
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)
"""
+145 -15
View File
@@ -2,15 +2,19 @@
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
@@ -87,9 +91,34 @@ class FilenameMatcher:
return False
class LanguageServerId(str, Enum):
class LanguageServerIdLike(Protocol):
"""
Enumeration of language servers supported by SolidLSP.
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`.
"""
def get_key(self) -> str:
"""
:return: the string key which identifies this language server in configuration files
"""
def get_source_fn_matcher(self) -> FilenameMatcher:
pass
def get_ls_class(self) -> type["SolidLanguageServer"]:
pass
def get_priority(self) -> int:
pass
def supports_implementation_request(self) -> bool:
return self.get_ls_class().supports_implementation_request()
class LanguageServerId(Enum):
"""
Enumeration of language servers directly supported by SolidLSP.
"""
CSHARP = "csharp"
@@ -320,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),
@@ -938,19 +970,117 @@ class LanguageServerId(str, Enum):
case _:
raise ValueError(f"Unhandled language: {self}")
@classmethod
def from_ls_class(cls, ls_class: type["SolidLanguageServer"]) -> Self:
"""
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
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 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:
"""
for enum_instance in cls:
if enum_instance.get_ls_class() == ls_class:
return enum_instance
raise ValueError(f"Unhandled language server class: {ls_class}")
Discover and execute language server adapter registration functions from entry points.
"""
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"
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)
@@ -959,7 +1089,7 @@ class LanguageServerConfig:
Configuration parameters for a language server instance
"""
ls_id: LanguageServerId
ls_id: LanguageServerIdLike
"""
defines the language server to use
"""
+2 -2
View File
@@ -2,7 +2,7 @@
This module contains the exceptions raised by the framework.
"""
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import LanguageServerIdLike
class SolidLSPException(Exception):
@@ -28,7 +28,7 @@ class SolidLSPException(Exception):
return isinstance(self.cause, LanguageServerTerminatedException)
def get_affected_language(self) -> LanguageServerId | None:
def get_affected_language(self) -> LanguageServerIdLike | None:
"""
:return: the affected language for the case where the exception is caused by the language server having terminated
"""
+9 -9
View File
@@ -12,7 +12,7 @@ from typing import IO, Any, AnyStr
from sensai.util.string import ToStringMixin
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_config import 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: LanguageServerId, 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: LanguageServerId,
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: LanguageServerId,
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: LanguageServerId,
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()
+17 -7
View File
@@ -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 LanguageServerId
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["LanguageServerId", 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: "LanguageServerId") -> 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
View File
@@ -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"),
]
+4 -4
View File
@@ -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",
-5
View File
@@ -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)