From dc97aba74a5fa339d5b3b1a824ccf311b7db04a3 Mon Sep 17 00:00:00 2001 From: riverolfe <139211102+riverolfe@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:43:51 +0200 Subject: [PATCH] Bridge external language server adapters through the registry (#2089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bridge external language server adapters through the registry The plugin mechanism (ExternalLanguageServerId + LanguageServerRegistry via solidlsp.language_server_registration entry points) was added but three places that consume language IDs still only iterate the built-in LanguageServerId enum, making externally-registered adapters unreachable from project.yml and the CLI. This change: - _determine_project_language_servers: also scans externally- registered LSes for auto-detection. - serena project create --ls: accepts registry keys as fallback. - No behavior change for built-in language servers. Motivation: enables first-class support for language servers like vhdl_ls that ship as separate packages rather than being vendored into the main repo. * Widen signatures for external language server support Follow-up to fix/external-ls-registry: - compute_language_server_support_composition: accept and return LanguageServerIdLike instead of LanguageServerId enum members only, since externally-registered adapters are also LanguageServerIdLike. - ProjectConfig.autogenerate: same widening for the languages parameter. - Two string-formatting sites in _determine_project_language_servers use .get_key() instead of .value so they work for either type. Type checker (ty) clean on src/serena and src/solidlsp; pre-existing diagnostics unrelated to this PR remain. * Refine based on review: registry as SoT, widen get_ls_priority Following @opcode81's review feedback: - Add LanguageServerRegistry.iter_registered_ls_ids() — a single iterator that yields all registered LSes (built-in enum + externals). - _determine_project_language_servers uses the new iterator instead of the split 'enum + dedup-externals' loop. - get_ls_priority now accepts LanguageServerIdLike, so user-configured priorities via serena_config.ls_priorities apply to external LSes too. Internally uses ls.get_key() (defined on both enum and ExternalLanguageServerId). - project create --ls drops the try/except fallback: resolves via registry directly. Unknown keys now report the full registry key list in the error message. Net diff vs previous attempt: +21/-27 vs +33/-14 (simpler). * Bridge external language server adapters through the registry --- src/serena/cli.py | 11 ++++++----- src/serena/config/serena_config.py | 28 ++++++++++++++++------------ src/serena/util/inspection.py | 8 ++++---- src/solidlsp/ls_config.py | 10 +++++++++- 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index d295b619..b44a2ac3 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -43,7 +43,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, LanguageServerIdLike +from solidlsp.ls_config import LanguageServerIdLike, LanguageServerRegistry from solidlsp.ls_types import SymbolKind from solidlsp.util.subprocess_util import subprocess_kwargs @@ -720,14 +720,15 @@ class ProjectCommands(AutoRegisteringGroup): if os.path.exists(yml_path): raise FileExistsError(f"Project file {yml_path} already exists.") - languages: list[LanguageServerId] = [] + languages: list[LanguageServerIdLike] = [] if language: + registry = LanguageServerRegistry.get_instance() for lang in language: + ls_key = lang.lower() try: - languages.append(LanguageServerId(lang.lower())) + languages.append(registry.resolve(ls_key)) except ValueError: - all_langs = [l.value for l in LanguageServerId] - raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}") + raise ValueError(f"Unknown language '{lang}'. Supported: {registry.get_keys()}") generated_conf = ProjectConfig.autogenerate( project_root=project_path, diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 4a003635..b7a78417 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -37,7 +37,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, LanguageServerIdLike, LanguageServerRegistry +from solidlsp.ls_config import LanguageServerIdLike, LanguageServerRegistry from ..analytics import RegisteredTokenCountEstimator from ..language_backend import BuiltinLanguageBackend, LanguageBackend, LanguageBackendRegistry @@ -368,11 +368,15 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): log.info("Determining suitable language servers for the project") # determine language servers to be considered and their priorities - ls_priorities = {} - for language in LanguageServerId: - priority = serena_config.get_ls_priority(language) + # the registry is the single source of truth — it includes both built-in enum members + # and externally-registered adapters (via solidlsp.language_server_registration entry points). + # priorities are user-configurable per-key via serena_config.ls_priorities (works for both kinds). + ls_priorities: dict[LanguageServerIdLike, int] = {} + registry = LanguageServerRegistry.get_instance() + for ls_id in registry.iter_registered_ls_ids(): + priority = serena_config.get_ls_priority(ls_id) if priority > 0: - ls_priorities[language] = priority + ls_priorities[ls_id] = priority log.debug("Language server priorities: %s", ls_priorities) ls_composition = compute_language_server_support_composition(project_root, list(ls_priorities.keys())) @@ -399,7 +403,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): if len(other_language_pairs) > 0 and interactive: print( "Detected and enabled main language server '%s' (%.2f%% of source files)." - % (top_language_pair[0].value, top_language_pair[1]) + % (top_language_pair[0].get_key(), top_language_pair[1]) ) print(f"Additionally detected {len(other_language_pairs)} other applicable language servers.\n") print("Note: Enable only servers for languages you need symbolic retrieval/editing capabilities for.") @@ -407,7 +411,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): print(" system-level installations/configuration (see Serena documentation).") print("\nWhich additional language servers do you want to enable?") for ls_id, perc in other_language_pairs: - enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (ls_id.value, perc), default=False) + enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (ls_id.get_key(), perc), default=False) if enable: language_servers_to_use.append(ls_id) print() @@ -421,7 +425,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_root: str | Path, serena_config: "SerenaConfig", project_name: str | None = None, - languages: list[LanguageServerId] | None = None, + languages: list[LanguageServerIdLike] | None = None, save_to_disk: bool = True, interactive: bool = False, asynchronous: bool = False, @@ -460,7 +464,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): ) languages_to_use = [l.get_key() for l in determined_languages] else: - languages_to_use = [lang.value for lang in languages] + languages_to_use = [lang.get_key() for lang in languages] config_with_comments, _ = cls._load_yaml_dict(PROJECT_TEMPLATE_FILE) config_with_comments["project_name"] = project_name config_with_comments["language_servers"] = languages_to_use @@ -1564,7 +1568,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): log.info(f"Using language backend from global configuration: {language_backend.name}") return language_backend - def get_ls_priority(self, ls_id: LanguageServerId) -> int: + def get_ls_priority(self, ls_id: LanguageServerIdLike) -> int: """ Gets the priority value associated with a language server @@ -1573,9 +1577,9 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): """ if self.ls_priorities is not None: try: - configured_value = self.ls_priorities.get(ls_id.value) + configured_value = self.ls_priorities.get(ls_id.get_key()) if configured_value is not None: return int(configured_value) except Exception as e: - log.error("Error reading language priority for %s: %s. Using default priority.", ls_id.value, e) + log.error("Error reading language priority for %s: %s. Using default priority.", ls_id.get_key(), e) return ls_id.get_priority() diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py index 8bc8a328..73362089 100644 --- a/src/serena/util/inspection.py +++ b/src/serena/util/inspection.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Iterator from typing import TypeVar from serena.util.file_system import find_all_non_ignored_files -from solidlsp.ls_config import LanguageServerId +from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike T = TypeVar("T") @@ -38,8 +38,8 @@ def iter_subclasses( def compute_language_server_support_composition( - repo_path: str, ls_ids: list[LanguageServerId] | None = None -) -> dict[LanguageServerId, float]: + repo_path: str, ls_ids: list[LanguageServerIdLike] | None = None +) -> dict[LanguageServerIdLike, float]: """ Determine the composition of a repository in terms of the language servers that can be used to analyze it. @@ -64,7 +64,7 @@ def compute_language_server_support_composition( matchers = {lang: lang.get_source_fn_matcher() for lang in ls_ids} # count files per language in a single pass over the files - ls_file_counts: dict[LanguageServerId, int] = {} + ls_file_counts: dict[LanguageServerIdLike, int] = {} recognised_files = 0 for file_path in all_files: # Use just the filename for matching, not the full path diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 80afde88..49c295ea 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -10,7 +10,7 @@ import logging import os import re import threading -from collections.abc import Iterable +from collections.abc import Iterable, Iterator from dataclasses import dataclass, field from enum import Enum from functools import cache @@ -1066,6 +1066,14 @@ class LanguageServerRegistry: return self._registered_language_servers[key] raise ValueError(f"Unknown language server key: '{key}'; Valid keys: {self.get_keys()}") + def iter_registered_ls_ids(self) -> Iterator[LanguageServerIdLike]: + """ + Iterate over all registered language servers (built-in + externally-registered via + entry points). Order follows ``get_keys()`` (alphabetical). + """ + for key in self.get_keys(): + yield self._registered_language_servers[key] + def register(self, ls_id: LanguageServerIdLike, allow_override: bool = False) -> None: """ :param ls_id: the identifier to register