Reify language backends, introducing LanguageBackendRegistry

* Many case differentiations in the agent code were replaced by
  method calls in the newly introduced LanguageBackend abstraction
* The registry allows new backends to be added dynamically
  (via Python packages that implement a specific entrypoint)
This commit is contained in:
Dominik Jain committed 2026-09-18 15:20:10 +02:00
1 parent 3ad4044c69
commit 3b898b671c
27 files changed
+571 -262

No files matched your search

+3 -3
View File
@@ -192,7 +192,7 @@ def autogen_tool_list(target_filename = "01-about/035_tools.md"):
def facades_section():
from serena.repl.facade import ApiScope
from serena.agent import SerenaAgent, SerenaConfig
from serena.config.serena_config import LanguageBackend
from serena.language_backend import BuiltinLanguageBackend
f.write("\n\n## Serena's REPL (Code Execution-Based Interface)\n\n")
f.write(
@@ -236,8 +236,8 @@ def autogen_tool_list(target_filename = "01-about/035_tools.md"):
api_scope = ApiScope()
agent = SerenaAgent(serena_config=SerenaConfig().with_headless_mode_overrides())
facades = []
for backend in LanguageBackend:
facades.extend(backend.create_facades(agent, api_scope))
for backend in BuiltinLanguageBackend:
facades.extend(backend.get_instance().create_facades(agent, api_scope))
facades.extend(agent.create_default_facade_list(api_scope))
text = TextBuilder()
+1 -1
View File
@@ -107,7 +107,7 @@ packages = ["src/serena", "src/interprompt", "src/solidlsp"]
max-line-length = 1000
[tool.ty.environment]
python-version = "3.11"
python-version = "3.11" # We configure the oldest Python version supported by Serena
# Analyze for all platforms rather than defaulting to the OS ty happens to run on. This keeps the
# check deterministic across the CI matrix (Linux/Windows/macOS) and lets platform-conditional stdlib
# members (e.g. subprocess.CREATE_NO_WINDOW, ctypes.windll, pwd) resolve without per-OS type-ignores.
+3 -2
View File
@@ -14,8 +14,9 @@ from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.project import Project
from serena.tools import (
CreateTextFileTool,
@@ -35,7 +36,7 @@ def make_agent() -> SerenaAgent:
"""Create an LSP-backed Serena agent for the Serena repository."""
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
project = Project(
project_root=str(REPO_PATH),
+3 -2
View File
@@ -9,8 +9,9 @@ from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.project import Project
from serena.tools import FindDeclarationTool
from solidlsp.ls_config import LanguageServerId
@@ -24,7 +25,7 @@ def make_agent(project_root: Path, language: LanguageServerId, project_name: str
"""Create an LSP-backed Serena agent for a single explicit project."""
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
project = Project(
project_root=str(project_root),
+3 -2
View File
@@ -8,8 +8,9 @@ from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.project import Project
from serena.tools import FindImplementationsTool
from solidlsp.ls_config import LanguageServerId
@@ -22,7 +23,7 @@ def make_agent(project_root: Path, language: LanguageServerId, project_name: str
"""Create an LSP-backed Serena agent for a single explicit project."""
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
project = Project(
project_root=str(project_root),
+6 -5
View File
@@ -11,8 +11,9 @@ import json
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, SerenaConfig
from serena.config.serena_config import SerenaConfig
from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.tools import (
FindReferencingSymbolsTool,
FindSymbolTool,
@@ -165,16 +166,16 @@ def run_jb_tools(agent: SerenaAgent) -> None:
)
def make_agent(backend: LanguageBackend) -> SerenaAgent:
def make_agent(backend: BuiltinLanguageBackend) -> SerenaAgent:
config = SerenaConfig.from_config_file()
config.web_dashboard = False
config.language_backend = backend
config.set_builtin_language_backend(backend)
return SerenaAgent(project=REPO_ROOT, serena_config=config)
if __name__ == "__main__":
# LSP backend
lsp_agent = make_agent(LanguageBackend.LSP)
lsp_agent = make_agent(BuiltinLanguageBackend.LSP)
try:
run_lsp_tools(lsp_agent)
run_backend_independent_tools(lsp_agent)
@@ -183,7 +184,7 @@ if __name__ == "__main__":
# JetBrains backend (requires a running IDE)
try:
jb_agent = make_agent(LanguageBackend.JETBRAINS)
jb_agent = make_agent(BuiltinLanguageBackend.JETBRAINS)
try:
run_jb_tools(jb_agent)
finally:
+3 -2
View File
@@ -9,8 +9,9 @@ from pathlib import Path
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, SerenaConfig
from serena.config.serena_config import SerenaConfig
from serena.constants import REPO_ROOT
from serena.language_backend import BuiltinLanguageBackend
from serena.tools import (
FindFileTool,
FindReferencingSymbolsTool,
@@ -26,7 +27,7 @@ from serena.tools import (
if __name__ == "__main__":
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
# project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy"
project = Path(REPO_ROOT)
agent = SerenaAgent(project=str(project), serena_config=serena_config)
+12 -39
View File
@@ -32,7 +32,6 @@ from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import (
AgentInterface,
LanguageBackend,
ModeSelectionDefinition,
ModeSelectionDefinitionWithAddedModes,
ModeSelectionDefinitionWithBaseModes,
@@ -44,7 +43,7 @@ from serena.config.serena_config import (
ToolInclusionDefinition,
)
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser
from serena.jetbrains import launch_coordinator as jetbrains_launch_coordinator
from serena.language_backend import BuiltinLanguageBackend, LanguageBackend
from serena.ls_manager import LanguageServerManager
from serena.memories.memory_manager import MemoryManager
from serena.project import Project
@@ -756,7 +755,7 @@ class SerenaAgent:
"os": platform.system(),
"dashboard": int(self.serena_config.web_dashboard),
"version": self.version,
"backend": self._language_backend.value,
"backend": self._language_backend.get_key(),
"context": self._context.name,
}
try:
@@ -1226,9 +1225,7 @@ class SerenaAgent:
msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}.\n"
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.get_key() for ls in proj.project_config.language_servers])
msg += f"Active language servers: {language_servers_str}.\n"
msg += self._language_backend.get_project_activation_statement(proj)
msg += f"File encoding: {proj.project_config.encoding}.\n"
# add list of memories (if memories are enabled)
@@ -1405,7 +1402,7 @@ class SerenaAgent:
"""
:return: whether this agent uses language server-based code analysis
"""
return self._language_backend == LanguageBackend.LSP
return self._language_backend == BuiltinLanguageBackend.LSP
def _activate_project(self, project: Project, update_active_modes: bool = True, update_active_tools: bool = True) -> bool:
"""
@@ -1428,14 +1425,12 @@ class SerenaAgent:
if project_backend is not None and project_backend != self._language_backend:
if self._agent_interface.is_tools():
raise ValueError(
f"Cannot activate project '{project.project_name}': it requires the {project_backend.value} backend, "
f"but this session was initialized with {self._language_backend.value}. "
f"Cannot activate project '{project.project_name}': it requires the {project_backend} backend, "
f"but this session was initialized with {self._language_backend}. "
f"Workarounds: (1) Use project activation at startup via the --project flag, "
f"(2) Configure one MCP server per backend in your client, (3) use the REPL interface."
)
log.info(
f"Switching language backend from {self._language_backend.value} to {project_backend.value} for project '{project.project_name}'"
)
log.info(f"Switching language backend from {self._language_backend} to {project_backend} for project '{project.project_name}'")
self._language_backend = project_backend
# shut down the previously active project to release its language server processes
@@ -1460,7 +1455,7 @@ class SerenaAgent:
def init_project_services() -> None:
self._run_project_activation_command(project)
self._init_active_project_language_backend()
self._language_backend.init_active_project(self)
# initialise the project's language backend in the background
self.issue_task(init_project_services)
@@ -1516,28 +1511,6 @@ class SerenaAgent:
except Exception:
log.exception(f"Unexpected error running activation_command for project '{project.project_name}'")
def _init_active_project_language_backend(self) -> None:
"""
Initialises the active project's language backend
"""
project = self._active_project
assert project is not None
# for LSP mode, start the language server manager
if self.get_language_backend().is_lsp():
with LogTime("Language server initialization", logger=log):
self.reset_language_server_manager()
# for JetBrains mode, search for plugin server and spawn IDE (if not found and launch command provided)
elif self.get_language_backend().is_jetbrains():
client = jetbrains_launch_coordinator.find_plugin_server(project)
if client is not None:
log.info("Found Serena JetBrains Plugin server: %s", client)
else:
log.info("Serena JetBrains Plugin server not found for project %s", project.project_name)
if self.serena_config.jetbrains_launch_command:
jetbrains_launch_coordinator.launch_and_wait_for_plugin_server(project, self.serena_config.jetbrains_launch_command)
def activate_project_from_path_or_name(
self, project_root_or_name: str, update_active_modes: bool = True, update_active_tools: bool = True
) -> bool:
@@ -1604,12 +1577,12 @@ class SerenaAgent:
else:
result_str += "No active project\n"
result_str += f"Agent interface: {self._agent_interface.value}\n"
result_str += f"Language backend: {self._language_backend.value}"
result_str += f"Language backend: {self._language_backend.get_key()}"
if self._active_project and self._active_project.project_config.language_backend is not None:
result_str += " (project override)"
result_str += f" (global default: {self.serena_config.language_backend.value})\n"
if self._language_backend.is_lsp() and self._active_project:
result_str += f"Language server status: {self._active_project.get_language_server_manager_status()}\n"
result_str += f" (global default: {self.serena_config.language_backend.get_key()})\n"
if self._active_project:
result_str += self._language_backend.get_config_overview_statement(self._active_project)
result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) + "\n"
result_str += f"Active context: {self._context.name}\n"
+7 -7
View File
@@ -24,7 +24,6 @@ from serena.config.client_setup import client_setup_handlers
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import (
AgentInterface,
LanguageBackend,
ModeSelectionDefinition,
ModeSelectionDefinitionWithAddedModes,
ProjectConfig,
@@ -39,6 +38,7 @@ from serena.constants import (
SERENAS_OWN_CONTEXT_YAMLS_DIR,
SERENAS_OWN_MODE_YAMLS_DIR,
)
from serena.language_backend import BuiltinLanguageBackend, LanguageBackendRegistry
from serena.prompt_factory import SerenaPromptFactory
from serena.tools import ActivateProjectTool
from serena.util.cli_util import AutoRegisteringGroup
@@ -181,14 +181,14 @@ class TopLevelCommands(AutoRegisteringGroup):
@click.option(
"--language-backend",
"-b",
type=click.Choice([b.value for b in LanguageBackend]),
default=LanguageBackend.LSP.value,
type=click.Choice([b.value for b in BuiltinLanguageBackend]),
default=BuiltinLanguageBackend.LSP.value,
show_default=True,
help="Default code intelligence backend (can be overridden in the project config).",
)
def init(language_backend: Literal["LSP", "JetBrains"] = "LSP") -> None:
click.echo(f"\nSerena version: {serena_version()}\n")
serena_config = SerenaConfig.init(language_backend=LanguageBackend(language_backend))
serena_config = SerenaConfig.init(builtin_language_backend=BuiltinLanguageBackend(language_backend))
click.echo(f"Configuration file: {serena_config.config_file_path}")
click.echo(f"Language backend: {language_backend}")
@@ -260,7 +260,7 @@ class TopLevelCommands(AutoRegisteringGroup):
)
@click.option(
"--language-backend",
type=click.Choice([lb.value for lb in LanguageBackend]),
type=click.Choice(LanguageBackendRegistry.get_instance().get_keys()),
default=None,
help="Override the configured language backend.",
)
@@ -393,7 +393,7 @@ class TopLevelCommands(AutoRegisteringGroup):
host=host,
port=port,
mode_selection_def=mode_selection_def,
language_backend=LanguageBackend.from_str(language_backend) if language_backend else None,
language_backend=LanguageBackendRegistry.get_instance().resolve(language_backend) if language_backend else None,
agent_interface=AgentInterface.from_str(agent_interface) if agent_interface else None,
enable_web_dashboard=enable_web_dashboard,
open_web_dashboard=open_web_dashboard,
@@ -968,7 +968,7 @@ class ProjectCommands(AutoRegisteringGroup):
logging.configure(level=logging.INFO)
project_path = os.path.abspath(project)
serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides()
serena_config.language_backend = LanguageBackend.LSP
serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
proj = Project.load(project_path, serena_config=serena_config)
# Create log file with timestamp
+2 -1
View File
@@ -25,6 +25,7 @@ TSymbol = TypeVar("TSymbol", bound=Symbol)
class CodeEditor(Generic[TSymbol], ABC):
def __init__(self, project: Project) -> None:
self.project = project
self.project_root = project.project_root
self.encoding = project.project_config.encoding
self.newline = project.line_ending.newline_str
@@ -83,7 +84,7 @@ class CodeEditor(Generic[TSymbol], ABC):
"""
Context manager for editing a file.
"""
if FileProxy.is_external_path(relative_path):
if FileProxy.is_external_path(relative_path, self.project):
raise ValueError(f"Cannot edit external file: {relative_path}")
with self._open_file_context(relative_path) as edited_file:
yield edited_file
+19 -74
View File
@@ -41,15 +41,13 @@ from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml
from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike, LanguageServerRegistry
from ..analytics import RegisteredTokenCountEstimator
from ..language_backend import BuiltinLanguageBackend, LanguageBackend, LanguageBackendRegistry
from ..util.class_decorators import singleton
from ..util.cli_util import ask_yes_no
from ..util.dataclass import get_dataclass_default
if TYPE_CHECKING:
from ..agent import SerenaAgent
from ..project import Project
from ..repl.facade import ApiScope, Facade
from ..tools.tools_base import Tool
log = logging.getLogger(__name__)
T = TypeVar("T")
@@ -251,67 +249,6 @@ class AgentInterface(Enum):
return self == AgentInterface.REPL
class LanguageBackend(Enum):
LSP = "LSP"
"""
Use the language server protocol (LSP), spawning freely available language servers
via the SolidLSP library that is part of Serena
"""
JETBRAINS = "JetBrains"
"""
Use the Serena plugin in your JetBrains IDE.
(requires the plugin to be installed and the project being worked on to be open in your IDE)
"""
@staticmethod
def from_str(backend_str: str) -> "LanguageBackend":
for backend in LanguageBackend:
if backend.value.lower() == backend_str.lower():
return backend
raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}")
def is_lsp(self) -> bool:
return self == LanguageBackend.LSP
def is_jetbrains(self) -> bool:
return self == LanguageBackend.JETBRAINS
def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]":
"""
:return: mapping from LSP tool classes to replacement tool classes (functional replacements)
"""
match self:
case LanguageBackend.LSP:
return {}
case LanguageBackend.JETBRAINS:
from ..tools import jetbrains_tools, symbol_tools
return {
symbol_tools.FindSymbolTool: jetbrains_tools.JetBrainsFindSymbolTool,
symbol_tools.GetSymbolsOverviewTool: jetbrains_tools.JetBrainsGetSymbolsOverviewTool,
symbol_tools.FindReferencingSymbolsTool: jetbrains_tools.JetBrainsFindReferencingSymbolsTool,
symbol_tools.FindImplementationsTool: jetbrains_tools.JetBrainsFindImplementationsTool,
symbol_tools.FindDeclarationTool: jetbrains_tools.JetBrainsFindDeclarationTool,
symbol_tools.RenameSymbolTool: jetbrains_tools.JetBrainsRenameTool,
symbol_tools.SafeDeleteSymbol: jetbrains_tools.JetBrainsSafeDeleteTool,
}
case _:
raise NotImplementedError()
def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]:
from ..repl.facade import Facade
if self.is_lsp():
from ..repl.api.lsp_api import LspApi
return [Facade.from_api(LspApi(agent), api_scope)]
elif self.is_jetbrains():
from ..repl.api.jb_api import JetBrainsApi
return [Facade.from_api(JetBrainsApi(agent), api_scope)]
return []
class LineEnding(Enum):
"""Line ending convention for file writes."""
@@ -680,7 +617,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
raise ValueError(f"symbol_info_budget cannot be negative, got: {symbol_info_budget}")
language_backend_value = data.get("language_backend")
language_backend = LanguageBackend.from_str(language_backend_value) if language_backend_value else None
language_backend = LanguageBackendRegistry.get_instance().resolve(language_backend_value) if language_backend_value else None
agent_interface_value = data.get("agent_interface")
agent_interface = AgentInterface.from_str(agent_interface_value) if agent_interface_value else None
@@ -742,7 +679,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
# map fields using non-primitive types to a YAML-compatible representation
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["language_backend"] = self.language_backend.get_key() if self.language_backend is not None else None
d["agent_interface"] = self.agent_interface.value if self.agent_interface is not None else None
d["line_ending"] = self.line_ending.value if self.line_ending is not None else None
@@ -1020,7 +957,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
Defaults to TOOLS for backward compatibility (as users without this settings will get this default).
The default for new users is defined in the template file.
"""
language_backend: LanguageBackend = LanguageBackend.LSP
language_backend: LanguageBackend = field(default_factory=lambda: BuiltinLanguageBackend.LSP.get_instance())
"""
the language backend to use for code understanding features
"""
@@ -1204,13 +1141,13 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
language_backend = get_dataclass_default(SerenaConfig, "language_backend")
if "language_backend" in loaded_commented_yaml:
backend_str = loaded_commented_yaml["language_backend"]
language_backend = LanguageBackend.from_str(backend_str)
language_backend = LanguageBackendRegistry.get_instance().resolve(backend_str)
else:
# backward compatibility (migrate Boolean field "jetbrains")
if "jetbrains" in loaded_commented_yaml:
num_migrations += 1
if loaded_commented_yaml["jetbrains"]:
language_backend = LanguageBackend.JETBRAINS
language_backend = BuiltinLanguageBackend.JETBRAINS.get_instance()
del loaded_commented_yaml["jetbrains"]
instance.language_backend = language_backend
@@ -1277,17 +1214,25 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
log.error(f"Error migrating configuration file: {e}")
return None
def set_builtin_language_backend(self, backend: BuiltinLanguageBackend) -> None:
"""
Sets the built-in language backend to use for code understanding features.
:param backend: the language backend to set
"""
self.language_backend = backend.get_instance()
@classmethod
def init(cls, language_backend: LanguageBackend) -> "SerenaConfig":
def init(cls, builtin_language_backend: BuiltinLanguageBackend) -> "SerenaConfig":
"""
Supports the config initialisation CLI command, allowing the user to configure fundamental settings before
the first launch.
:param language_backend: the language backend to use
:param builtin_language_backend: the language backend to use
:return: the created SerenaConfig instance
"""
config = cls.from_config_file()
config.language_backend = language_backend
config.language_backend = builtin_language_backend.get_instance()
config._save()
return config
@@ -1479,7 +1424,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
commented_yaml["projects"] = sorted({str(project.project_root) for project in self.projects})
# convert language backend to string
commented_yaml["language_backend"] = self.language_backend.value
commented_yaml["language_backend"] = self.language_backend.get_key()
# convert agent interface to string (None if not configured)
commented_yaml["agent_interface"] = self.agent_interface.value if self.agent_interface is not None else None
@@ -1609,7 +1554,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
log.info(f"Using agent interface '{agent_interface.value}' ({source})")
return agent_interface
def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False):
def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False) -> LanguageBackend:
language_backend = self.language_backend
if project_config and project_config.language_backend is not None:
language_backend = project_config.language_backend
+1 -1
View File
@@ -651,7 +651,7 @@ class SerenaDashboardAPI:
modes=modes_info,
active_tools=active_tools,
agent_interface=self._agent.get_agent_interface().value,
language_backend=self._agent.get_language_backend().value,
language_backend=self._agent.get_language_backend().get_key(),
facades=facades,
tool_stats_summary=tool_stats_summary,
registered_projects=registered_projects,
+105
View File
@@ -0,0 +1,105 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from typing import TYPE_CHECKING
from overrides import override
from serena.code_editor import JetBrainsCodeEditor
from serena.jetbrains import launch_coordinator
from serena.language_backend import BuiltinLanguageBackend, LanguageBackend
from ..util.file_proxy import FileProxy, LocalProjectFileProxy
from . import jetbrains_types as jb
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor
from serena.project import Project
from serena.repl.facade import ApiScope, Facade
from serena.tools import Tool
log = logging.getLogger(__name__)
class LanguageBackendJetBrains(LanguageBackend):
def __init__(self):
super().__init__(BuiltinLanguageBackend.JETBRAINS.value)
@override
def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]":
from ..tools import jetbrains_tools, symbol_tools
return {
symbol_tools.FindSymbolTool: jetbrains_tools.JetBrainsFindSymbolTool,
symbol_tools.GetSymbolsOverviewTool: jetbrains_tools.JetBrainsGetSymbolsOverviewTool,
symbol_tools.FindReferencingSymbolsTool: jetbrains_tools.JetBrainsFindReferencingSymbolsTool,
symbol_tools.FindImplementationsTool: jetbrains_tools.JetBrainsFindImplementationsTool,
symbol_tools.FindDeclarationTool: jetbrains_tools.JetBrainsFindDeclarationTool,
symbol_tools.RenameSymbolTool: jetbrains_tools.JetBrainsRenameTool,
symbol_tools.SafeDeleteSymbol: jetbrains_tools.JetBrainsSafeDeleteTool,
}
@override
def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]:
from ..repl.api.jb_api import JetBrainsApi
from ..repl.facade import Facade
return [Facade.from_api(JetBrainsApi(agent), api_scope)]
@override
def init_active_project(self, agent: "SerenaAgent") -> None:
project = agent.get_active_project_or_raise()
client = launch_coordinator.find_plugin_server(project)
if client is not None:
log.info("Found Serena JetBrains Plugin server: %s", client)
else:
log.info("Serena JetBrains Plugin server not found for project %s", project.project_name)
launch_command = agent.serena_config.jetbrains_launch_command
if launch_command:
launch_coordinator.launch_and_wait_for_plugin_server(project, launch_command)
@override
def shutdown_active_project(self, project: "Project", timeout: float) -> None:
pass
@override
def create_code_editor(self, project: "Project") -> "CodeEditor":
return JetBrainsCodeEditor(project)
@override
def is_source_file(self, abs_path: str, project: "Project") -> bool:
# no distinction is made; every file is potentially a source file
return True
@override
def is_external_path(self, relative_path: str) -> bool:
return relative_path.startswith(jb.JB_EXTERNAL_FILE_PREFIX)
@override
def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy:
if self.is_external_path(relative_path):
return JetBrainsFileProxy(relative_path, project)
return LocalProjectFileProxy(relative_path, project)
class JetBrainsFileProxy(FileProxy):
"""
Retrieves the contents of a file from the JetBrains plugin via the plugin client, given its relative path,
which may be an external path (e.g., "<ext:FileUtil.class|472e0a13>")
"""
def __init__(self, relative_path: str, project: "Project"):
self._relative_path = relative_path
self._project = project
def get_contents(self) -> str:
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
client = JetBrainsPluginClient.from_project(self._project)
return client.read_file(self._relative_path)
def get_relative_path(self) -> str:
return self._relative_path
def is_glob_supported(self):
return False
-8
View File
@@ -8,14 +8,6 @@ Prefix used for in relative paths of symbols that are from external libraries (i
"""
def is_external_path(relative_path: str):
"""
:param relative_path: a relative path (e.g., from a symbol's `relative_path` field)
:return: whether the path is an external path (i.e., from a library, not the user's codebase)
"""
return relative_path.startswith(JB_EXTERNAL_FILE_PREFIX)
class PluginStatusDTO(TypedDict):
project_root: str
plugin_version: str
+251
View File
@@ -0,0 +1,251 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
import logging
import threading
from abc import ABC, abstractmethod
from enum import Enum
from functools import cache
from typing import TYPE_CHECKING
from serena.util.file_proxy import FileProxy
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor
from serena.project import Project
from serena.repl.facade import ApiScope, Facade
from serena.tools import Tool
class LanguageBackend(ABC):
def __init__(self, key: str):
"""
:param key: the key by which the backend is identified in the registry and in configuration
"""
self._key = key
def __str__(self):
return self._key
def get_key(self) -> str:
"""
:return: the key by which the backend is identified in the registry and in configuration
"""
return self._key
def is_lsp(self):
return self.get_key() == BuiltinLanguageBackend.LSP.value
def is_jetbrains(self):
return self.get_key() == BuiltinLanguageBackend.JETBRAINS.value
@property
def name(self):
return self.get_key()
@abstractmethod
def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]":
"""
:return: mapping from LSP tool classes to replacement tool classes (functional replacements)
"""
@abstractmethod
def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]:
"""
Creates backend-specific facades for the given agent and API scope.
:param agent: the agent
:param api_scope: the API scope defining active facade methods
:return: the list of facades to be used by the agent for this backend
"""
@abstractmethod
def init_active_project(self, agent: "SerenaAgent") -> None:
"""
Initialises the backend for the given agent's newly activated project.
:param agent: the agent, which has just set a new active project
"""
@abstractmethod
def shutdown_active_project(self, project: "Project", timeout: float) -> None:
"""
Cleans up, freeing resources, after a project has been deactivated.
:param project: the project
:param timeout: the timeout, in seconds, after which to give up on graceful shutdown
"""
def get_project_activation_statement(self, project: "Project") -> str:
"""
:return: a statement to add to the project activation message
"""
return ""
def get_config_overview_statement(self, project: "Project") -> str:
"""
:return: a statement to add to the project configuration overview
"""
return ""
@abstractmethod
def create_code_editor(self, project: "Project") -> "CodeEditor":
pass
@abstractmethod
def is_source_file(self, abs_path: str, project: "Project") -> bool:
"""
Determines whether the given absolute path corresponds to a source file that can (potentially) be processed/understood by the backend.
:param abs_path: the absolute path to an existing file
:param project: the project in which the file is located
:return: True if the file is a source file for this backend (or the backend does not specifically make distinctions),
False otherwise
"""
@abstractmethod
def is_external_path(self, relative_path: str) -> bool:
"""
Determines whether the given relative path corresponds to a file that is external to the project (e.g. a dependency file).
Virtually all of Serena's interfaces use `relative_path` (relative to the project root) to refer to files, but some backends
may need to support project-external files. In this case, the external path should be encoded in the `relative_path` parameter
(e.g. "<ext:/path/to/whatever>") rather than this being an actual relative path that points outside the project root.
Therefore, information about the project in question is deliberately not provided to this method.
:param relative_path: the relative path to a file within the project or an encoded external path.
The path can be assumed to have been provided by the backend itself.
:return: whether the file is considered external to the project by this backend
"""
@abstractmethod
def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy:
"""
Creates a file proxy for the given relative path in the given project.
:param relative_path: the relative path to a file within the project or an encoded external path.
:param project: the project
:return: a file proxy for the given file
"""
class BuiltinLanguageBackend(Enum):
LSP = "LSP"
"""
Use the language server protocol (LSP), spawning freely available language servers
via the SolidLSP library that is part of Serena
"""
JETBRAINS = "JetBrains"
"""
Use the Serena plugin in your JetBrains IDE.
(requires the plugin to be installed and the project being worked on to be open in your IDE)
"""
@staticmethod
def from_str(backend_str: str) -> "BuiltinLanguageBackend":
for backend in BuiltinLanguageBackend:
if backend.value.lower() == backend_str.lower():
return backend
raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in BuiltinLanguageBackend]}")
@cache
def get_instance(self) -> LanguageBackend:
if self == BuiltinLanguageBackend.LSP:
from .lsp.lsp_backend import LanguageBackendLSP
return LanguageBackendLSP()
elif self == BuiltinLanguageBackend.JETBRAINS:
from .jetbrains.jetbrains_backend import LanguageBackendJetBrains
return LanguageBackendJetBrains()
else:
raise NotImplementedError
class LanguageBackendRegistry:
"""
Registry of language backends
"""
REGISTRATION_ENTRY_POINT_GROUP = "serena.language_backend_registration"
"""
entry point group for language backend registration functions; each function should call use
`LanguageBackendRegistry.get_instance().register(...)` to register a backend
"""
_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_backends_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_backends: dict[str, LanguageBackend] = {}
# auto-register built-in language backends
for builtin_backend in BuiltinLanguageBackend:
self._registered_backends[builtin_backend.value] = builtin_backend.get_instance()
@classmethod
def _discover_backends_from_entry_points(cls) -> None:
"""
Discover and execute language server adapter registration functions from entry points.
"""
log.debug("Discovering language backend 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 backend entry point '%s' from %s: %s",
entry_point.name,
get_distribution_name(entry_point),
error,
)
def resolve(self, key: str) -> LanguageBackend:
if key in self._registered_backends:
return self._registered_backends[key]
raise ValueError(f"Unknown language backend key: '{key}'; Valid keys: {self.get_keys()}")
def register(self, backend: LanguageBackend, allow_override: bool = False) -> None:
"""
:param backend: the backend to register
:param allow_override: whether to allow overriding an existing registration with the same key
"""
key = backend.get_key()
log.info("Registering language backend: %s (class=%s)", key, backend.__class__.__name__)
if backend.get_key() in self._registered_backends and not allow_override:
raise ValueError(f"Language backend already registered: {key}")
self._registered_backends[key] = backend
def get_keys(self) -> list[str]:
"""
:return: the sorted list of all registered string keys
"""
return sorted(self._registered_backends.keys())
+80
View File
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from typing import TYPE_CHECKING
from overrides import override
from sensai.util.logging import LogTime
from serena.language_backend import BuiltinLanguageBackend, LanguageBackend
from serena.util.file_proxy import FileProxy, LocalProjectFileProxy
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import LanguageServerCodeEditor
from serena.project import Project
from serena.repl.facade import ApiScope, Facade
from serena.tools import Tool
log = logging.getLogger(__name__)
class LanguageBackendLSP(LanguageBackend):
def __init__(self):
super().__init__(BuiltinLanguageBackend.LSP.value)
@override
def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]":
return {}
@override
def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]:
from ..repl.api.lsp_api import LspApi
from ..repl.facade import Facade
return [Facade.from_api(LspApi(agent), api_scope)]
@override
def init_active_project(self, agent: "SerenaAgent") -> None:
with LogTime("Language server initialization", logger=log):
agent.reset_language_server_manager()
@override
def shutdown_active_project(self, project: "Project", timeout: float) -> None:
# nothing to do; the language server manager is already shut down by the project itself
pass
@override
def get_project_activation_statement(self, project: "Project") -> str:
language_servers_str = ", ".join([ls.get_key() for ls in project.project_config.language_servers])
return f"Active language servers: {language_servers_str}.\n"
@override
def get_config_overview_statement(self, project: "Project") -> str:
return f"Language server status: {project.get_language_server_manager_status()}\n"
@override
def create_code_editor(self, project: "Project") -> "LanguageServerCodeEditor":
from serena.code_editor import LanguageServerCodeEditor
from serena.symbol import LanguageServerSymbolRetriever
symbol_retriever = LanguageServerSymbolRetriever(project)
return LanguageServerCodeEditor(symbol_retriever)
@override
def is_source_file(self, abs_path: str, project: "Project") -> bool:
is_file_in_supported_languages = False
for language in project.project_config.language_servers:
fn_matcher = language.get_source_fn_matcher()
if fn_matcher.is_relevant_filename(abs_path):
is_file_in_supported_languages = True
break
return is_file_in_supported_languages
@override
def is_external_path(self, relative_path: str) -> bool:
# LSP backend currently uses only true project-relative paths
return False
@override
def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy:
return LocalProjectFileProxy(relative_path, project)
+1 -1
View File
@@ -219,7 +219,7 @@ class DiagnosticsContext:
DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]"
def __init__(self, agent: "SerenaAgent", *edited_relative_paths: str, enable: bool = ENABLE_DIAGNOSTICS_DEFAULT) -> None:
self._is_diagnostics_enabled = enable and agent.is_using_language_server()
self._is_diagnostics_enabled = enable and agent.get_language_backend()
self._edited_files = [EditedFilePath(path, path) for path in edited_relative_paths]
self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None
self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None
+2 -1
View File
@@ -26,8 +26,9 @@ from serena.agent import (
SerenaAgent,
)
from serena.config.context_mode import SerenaAgentContext
from serena.config.serena_config import AgentInterface, LanguageBackend, ModeSelectionDefinition, SerenaConfig
from serena.config.serena_config import AgentInterface, ModeSelectionDefinition, SerenaConfig
from serena.constants import DEFAULT_CONTEXT, SERENA_LOG_FORMAT
from serena.language_backend import LanguageBackend
from serena.tools import Tool, ToolCallError
from serena.util.exception import show_fatal_exception_safe
from serena.util.logging import MemoryLogHandler
+16 -16
View File
@@ -12,11 +12,11 @@ from sensai.util.logging import LogTime
from sensai.util.string import TextBuilder, ToStringMixin
from serena.config.serena_config import (
LanguageBackend,
ProjectConfig,
ProjectConfigAutoGenerationMode,
SerenaConfig,
)
from serena.language_backend import LanguageBackend
from serena.ls_manager import LanguageServerFactory, LanguageServerManager
from serena.memories.memory_manager import MemoryManager
from serena.util.file_proxy import FileCollection, FileProxy
@@ -128,7 +128,7 @@ class Project(ToStringMixin):
@property
def language_backend(self) -> LanguageBackend:
# The backend configuration is fundamentally owned by the agent, so it takes
# precedence. (Note: The agent does not necessary honour the project's choice,
# precedence. (Note: The agent does not necessarily honour the project's choice,
# as it may be invalid.)
if self._agent is not None:
return self._agent.get_language_backend()
@@ -236,18 +236,10 @@ class Project(ToStringMixin):
# check code file restriction (depending on backend)
if ignore_non_source_files:
# apply restriction only for LSP backend, which enumerates known languages
# and therefore can determine whether a file is a source file or not
if self.language_backend.is_lsp():
if os.path.isfile(abs_path):
is_file_in_supported_language = False
for language in self.project_config.language_servers:
fn_matcher = language.get_source_fn_matcher()
if fn_matcher.is_relevant_filename(abs_path):
is_file_in_supported_language = True
break
if not is_file_in_supported_language:
return True
if os.path.isfile(abs_path):
# non-source files are ignored
if not self.language_backend.is_source_file(abs_path, self):
return True
# Create normalized path for consistent handling
rel_path = Path(relative_path)
@@ -336,7 +328,7 @@ class Project(ToStringMixin):
:param relative_path: the path to validate, relative to the project root
:param require_not_ignored: if True, the path must not be ignored according to the project's ignore settings
"""
if FileProxy.is_external_path(relative_path):
if FileProxy.is_external_path(relative_path, self):
return
if not self.is_path_in_project(relative_path):
@@ -392,7 +384,7 @@ class Project(ToStringMixin):
:param skip_ignored_files: whether to skip ignored files; has no effect if `code_files_only` is True
:return:
"""
if FileProxy.is_external_path(relative_path):
if FileProxy.is_external_path(relative_path, self):
# single external path: create appropriate proxy
file_collection = FileCollection([FileProxy.from_project_relative_path(self, relative_path)])
else:
@@ -621,6 +613,14 @@ class Project(ToStringMixin):
return 0
def shutdown(self, timeout: float = 2.0) -> None:
"""
Shuts down the project, calling the language backend-specific shutdown of the active project.
:param timeout: the timeout, in seconds
"""
# clean up internal resources
if self.language_server_manager is not None:
self.language_server_manager.stop_all(save_cache=True, timeout=timeout)
self.language_server_manager = None
# trigger additional backend-specific shutdown
self.language_backend.shutdown_active_project(self, timeout=timeout)
+3 -2
View File
@@ -12,8 +12,9 @@ from flask import Flask, Response, abort, request
from pydantic import BaseModel
from sensai.util.logging import LogTime
from serena.config.serena_config import LanguageBackend, SerenaConfig
from serena.config.serena_config import SerenaConfig
from serena.constants import SerenaPorts
from serena.language_backend import BuiltinLanguageBackend
if TYPE_CHECKING:
from serena.project import Project
@@ -73,7 +74,7 @@ class ProjectServer:
port = self.PORT
serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides()
serena_config.language_backend = LanguageBackend.LSP
serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP)
self._agent = SerenaAgent(serena_config=serena_config)
self._loaded_projects_by_root: dict[str, "Project"] = {}
+1 -9
View File
@@ -362,17 +362,9 @@ class FacadeApi(ABC):
"""
:return: a code editor for the active project, using the active language backend
"""
from serena.code_editor import JetBrainsCodeEditor, LanguageServerCodeEditor
from serena.symbol import LanguageServerSymbolRetriever
project = self._get_project()
backend = self._agent.get_language_backend()
if backend.is_lsp():
return LanguageServerCodeEditor(LanguageServerSymbolRetriever(project))
elif backend.is_jetbrains():
return JetBrainsCodeEditor(project)
else:
raise ValueError(f"Unsupported language backend: {backend}")
return backend.create_code_editor(project)
class FacadeMethod:
+7 -11
View File
@@ -2,7 +2,6 @@
import json
from serena.config.serena_config import LanguageBackend
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager
from serena.project_server import ProjectServerClient
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional
@@ -67,13 +66,10 @@ class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveP
return tool.apply(**json.loads(tool_params_json))
def _is_project_server_required(self, tool: Tool) -> bool:
match self.agent.get_language_backend():
case LanguageBackend.JETBRAINS:
return False
case LanguageBackend.LSP:
# Note: As long as only read-only tools are considered, only symbolic tools require the project server.
# But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers.
assert tool.is_readonly()
return tool.is_symbolic()
case _:
raise NotImplementedError
# The project server is relevant to the LSP backend only
if not self.agent.get_language_backend().is_lsp():
return False
# Note: As long as only read-only tools are considered, only symbolic tools require the project server.
# But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers.
assert tool.is_readonly()
return tool.is_symbolic()
+2 -18
View File
@@ -16,7 +16,6 @@ from sensai.util.helper import mark_used
from sensai.util.string import dict_string
from serena.code_editor import EditedFileContext
from serena.config.serena_config import LanguageBackend
from serena.lsp.lsp_diagnostics import DiagnosticsContext
from serena.memories.memory_manager import MemoryManager
from serena.project import Project
@@ -29,7 +28,7 @@ from solidlsp.ls_exceptions import SolidLSPException
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor, LanguageServerCodeEditor
from serena.code_editor import CodeEditor
from serena.symbol import LanguageServerSymbolRetriever
@@ -67,22 +66,7 @@ class Component(ABC):
return self.agent.get_active_project_or_raise()
def create_code_editor(self) -> "CodeEditor":
from ..code_editor import JetBrainsCodeEditor
match self.agent.get_language_backend():
case LanguageBackend.LSP:
return self.create_ls_code_editor()
case LanguageBackend.JETBRAINS:
return JetBrainsCodeEditor(project=self.project)
case _:
raise ValueError
def create_ls_code_editor(self) -> "LanguageServerCodeEditor":
from ..code_editor import LanguageServerCodeEditor
if not self.agent.is_using_language_server():
raise Exception("Cannot create LanguageServerCodeEditor; agent is not in language server mode.")
return LanguageServerCodeEditor(self.create_language_server_symbol_retriever())
return self.agent.get_language_backend().create_code_editor(self.project)
class ToolMarker:
+3 -32
View File
@@ -6,8 +6,6 @@ from abc import ABC, abstractmethod
from collections.abc import Iterator
from typing import TYPE_CHECKING, Self
from serena.jetbrains import jetbrains_types as jb
if TYPE_CHECKING:
from serena.project import Project
@@ -30,19 +28,15 @@ class FileProxy(ABC):
"""
@staticmethod
def is_external_path(relative_path: str) -> bool:
def is_external_path(relative_path: str, project: "Project") -> bool:
"""
:return: whether the given relative path is an encoded external path (not a local project file)
"""
# This is intended to be extended once we also support external paths in other backends
return jb.is_external_path(relative_path)
return project.language_backend.is_external_path(relative_path)
@classmethod
def from_project_relative_path(cls, project: "Project", relative_path: str) -> "FileProxy":
if cls.is_external_path(relative_path):
if project.language_backend.is_jetbrains():
return JetBrainsFileProxy(relative_path, project)
return LocalProjectFileProxy(relative_path, project)
return project.language_backend.create_file_proxy(relative_path, project)
class LocalProjectFileProxy(FileProxy):
@@ -62,29 +56,6 @@ class LocalProjectFileProxy(FileProxy):
return True
class JetBrainsFileProxy(FileProxy):
"""
Retrieves the contents of a file from the JetBrains plugin via the plugin client, given its relative path,
which may be an external path (e.g., "<ext:FileUtil.class|472e0a13>")
"""
def __init__(self, relative_path: str, project: "Project"):
self._relative_path = relative_path
self._project = project
def get_contents(self) -> str:
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
client = JetBrainsPluginClient.from_project(self._project)
return client.read_file(self._relative_path)
def get_relative_path(self) -> str:
return self._relative_path
def is_glob_supported(self):
return False
class FileCollection:
def __init__(self, file_proxies: list[FileProxy]):
self._file_proxies = file_proxies
+24 -22
View File
@@ -12,13 +12,13 @@ from serena.agent import SerenaAgent
from serena.config.serena_config import (
DEFAULT_PROJECT_SERENA_FOLDER_LOCATION,
AgentInterface,
LanguageBackend,
ProjectConfig,
RegisteredProject,
SerenaConfig,
SerenaConfigError,
)
from serena.constants import PROJECT_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME
from serena.language_backend import BuiltinLanguageBackend
from serena.project import MemoryManager, Project
from solidlsp.ls_config import LanguageServerId
from test.conftest import create_default_serena_config
@@ -178,15 +178,16 @@ class TestProjectConfigLanguageBackend:
config = ProjectConfig(
project_name="test",
language_servers=[LanguageServerId.PYTHON],
language_backend=LanguageBackend.JETBRAINS,
language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(),
)
assert config.language_backend == LanguageBackend.JETBRAINS
assert config.language_backend is not None
assert config.language_backend.is_jetbrains()
def test_language_backend_roundtrips_through_yaml(self):
config = ProjectConfig(
project_name="test",
language_servers=[LanguageServerId.PYTHON],
language_backend=LanguageBackend.JETBRAINS,
language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(),
)
d = config._to_yaml_dict()
assert d["language_backend"] == "JetBrains"
@@ -207,7 +208,8 @@ class TestProjectConfigLanguageBackend:
data["languages"] = ["python"]
data["language_backend"] = "JetBrains"
config = ProjectConfig._from_dict(data, local_override_keys=[])
assert config.language_backend == LanguageBackend.JETBRAINS
assert config.language_backend is not None
assert config.language_backend.is_jetbrains()
def test_language_backend_none_when_missing_from_dict(self):
"""Test that _from_dict handles missing language_backend gracefully."""
@@ -284,20 +286,20 @@ class TestAgentInterface:
def _make_config_with_project(
project_name: str,
language_backend: LanguageBackend | None = None,
global_backend: LanguageBackend = LanguageBackend.LSP,
language_backend: BuiltinLanguageBackend | None = None,
global_backend: BuiltinLanguageBackend = BuiltinLanguageBackend.LSP,
) -> tuple[SerenaConfig, str]:
"""Create a SerenaConfig with a single registered project and return (config, project_name)."""
config = SerenaConfig(
log_level=logging.ERROR,
language_backend=global_backend,
language_backend=global_backend.get_instance(),
).with_headless_mode_overrides()
project = Project(
project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"),
project_config=ProjectConfig(
project_name=project_name,
language_servers=[LanguageServerId.PYTHON],
language_backend=language_backend,
language_backend=language_backend.get_instance() if language_backend is not None else None,
),
serena_config=config,
)
@@ -310,7 +312,7 @@ class TestEffectiveLanguageBackend:
def test_default_backend_is_global(self):
"""When no project override, effective backend matches global config."""
config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=LanguageBackend.LSP)
config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
agent = SerenaAgent(project=name, serena_config=config)
try:
assert agent.get_language_backend().is_lsp()
@@ -320,7 +322,7 @@ class TestEffectiveLanguageBackend:
def test_project_overrides_global_backend(self):
"""When startup project has language_backend set, it overrides the global."""
config, name = _make_config_with_project(
"test_jetbrains", language_backend=LanguageBackend.JETBRAINS, global_backend=LanguageBackend.LSP
"test_jetbrains", language_backend=BuiltinLanguageBackend.JETBRAINS, global_backend=BuiltinLanguageBackend.LSP
)
agent = SerenaAgent(project=name, serena_config=config)
try:
@@ -332,18 +334,18 @@ class TestEffectiveLanguageBackend:
"""When no startup project is provided, effective backend is the global one."""
config = SerenaConfig(
log_level=logging.ERROR,
language_backend=LanguageBackend.LSP,
language_backend=BuiltinLanguageBackend.LSP.get_instance(),
).with_headless_mode_overrides()
agent = SerenaAgent(project=None, serena_config=config)
try:
assert agent.get_language_backend() == LanguageBackend.LSP
assert agent.get_language_backend().is_lsp()
finally:
agent.on_shutdown(timeout=5)
def test_activate_project_rejects_backend_mismatch(self):
"""Post-init activation of a project with mismatched backend raises ValueError."""
# Start with LSP backend
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP)
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
# Add a second project that requires JetBrains
jb_project = Project(
@@ -351,7 +353,7 @@ class TestEffectiveLanguageBackend:
project_config=ProjectConfig(
project_name="jb_proj",
language_servers=[LanguageServerId.JAVA],
language_backend=LanguageBackend.JETBRAINS,
language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(),
),
serena_config=config,
)
@@ -366,14 +368,14 @@ class TestEffectiveLanguageBackend:
def test_activate_project_switches_backend_with_repl_interface(self):
"""With the REPL interface, post-init activation of a project with a different backend switches the backend."""
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP)
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
config.agent_interface = AgentInterface.REPL
jb_project = Project(
project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "java" / "test_repo"),
project_config=ProjectConfig(
project_name="jb_proj",
language_servers=[LanguageServerId.JAVA],
language_backend=LanguageBackend.JETBRAINS,
language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(),
),
serena_config=config,
)
@@ -381,12 +383,12 @@ class TestEffectiveLanguageBackend:
agent = SerenaAgent(project=name, serena_config=config)
try:
assert agent.get_language_backend() == LanguageBackend.LSP
assert agent.get_language_backend().is_lsp()
assert "s.lsp" in agent.get_repl().entrypoint.overview()
# the backend and everything depending on it follow the activated project
agent.activate_project_from_path_or_name("jb_proj")
assert agent.get_language_backend() == LanguageBackend.JETBRAINS
assert agent.get_language_backend().is_jetbrains()
overview = agent.get_repl().entrypoint.overview()
assert "s.jb" in overview and "s.lsp" not in overview
assert "jetbrains" in [m.name for m in agent.get_active_modes().get_modes(include_background_base_modes=True)]
@@ -395,7 +397,7 @@ class TestEffectiveLanguageBackend:
def test_activate_project_allows_matching_backend(self):
"""Post-init activation of a project with matching backend succeeds."""
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP)
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
# Add a second project that also uses LSP
lsp_project2 = Project(
@@ -403,7 +405,7 @@ class TestEffectiveLanguageBackend:
project_config=ProjectConfig(
project_name="lsp_proj2",
language_servers=[LanguageServerId.PYTHON],
language_backend=LanguageBackend.LSP,
language_backend=BuiltinLanguageBackend.LSP.get_instance(),
),
serena_config=config,
)
@@ -418,7 +420,7 @@ class TestEffectiveLanguageBackend:
def test_activate_project_allows_none_backend(self):
"""Post-init activation of a project with no backend override succeeds."""
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP)
config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP)
# Add a second project with no backend override
proj2 = Project(
@@ -18,6 +18,7 @@ from typing import Any
import pytest
from serena.code_editor import CodeEditor
from serena.language_backend import BuiltinLanguageBackend
from serena.util import file_system
@@ -44,7 +45,14 @@ class _InMemoryEditedFile(CodeEditor.EditedFile):
class _StubCodeEditor(CodeEditor[Any]):
"""A ``CodeEditor`` whose only inherited behaviour under test is the file-saving path."""
class DummyProject:
"""A dummy project object with only the attributes needed to construct a ``CodeEditor``."""
def __init__(self) -> None:
self.language_backend = BuiltinLanguageBackend.LSP.get_instance()
def __init__(self, project_root: str, encoding: str = "utf-8", newline: str | None = None) -> None:
self.project = self.DummyProject()
self.project_root = project_root
self.encoding = encoding
self.newline = newline
+5 -3
View File
@@ -7,7 +7,8 @@ from unittest.mock import MagicMock
import pytest
from serena.config.serena_config import ApiInclusionDefinition, LanguageBackend
from serena.config.serena_config import ApiInclusionDefinition
from serena.language_backend import BuiltinLanguageBackend
from serena.repl.api.edit_api import EditApi
from serena.repl.api.lsp_api import LspApi
from serena.repl.external_project import ExternalProjectExecution
@@ -83,10 +84,11 @@ class TestReplExecution:
overview = rebuilt_repl.execute("facades()", session)
assert "s.edit" in overview and "s.lsp" not in overview
@pytest.mark.parametrize("backend", [LanguageBackend.LSP, LanguageBackend.JETBRAINS])
@pytest.mark.parametrize("builtin_backend", [BuiltinLanguageBackend.LSP, BuiltinLanguageBackend.JETBRAINS])
@pytest.mark.parametrize("read_only", [True, False])
def test_external_project_dispatch(self, backend: LanguageBackend, read_only: bool) -> None:
def test_external_project_dispatch(self, builtin_backend: BuiltinLanguageBackend, read_only: bool) -> None:
agent = MagicMock()
backend = builtin_backend.get_instance()
agent.get_language_backend.return_value = backend
class FakeExternalProject(ExternalProjectExecution):