From 3b898b671cd612113895f0731a1d544f0f61668d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 17 Sep 2026 15:40:34 +0200 Subject: [PATCH] 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) --- docs/autogen_docs.py | 6 +- pyproject.toml | 2 +- scripts/demo_diagnostics.py | 5 +- scripts/demo_find_defining_symbol.py | 5 +- scripts/demo_find_implementing_symbol.py | 5 +- scripts/demo_progressive_tool_shortening.py | 11 +- scripts/demo_run_tools.py | 5 +- src/serena/agent.py | 51 +--- src/serena/cli.py | 14 +- src/serena/code_editor.py | 3 +- src/serena/config/serena_config.py | 93 ++----- src/serena/dashboard.py | 2 +- src/serena/jetbrains/jetbrains_backend.py | 105 ++++++++ src/serena/jetbrains/jetbrains_types.py | 8 - src/serena/language_backend.py | 251 ++++++++++++++++++ src/serena/lsp/lsp_backend.py | 80 ++++++ src/serena/lsp/lsp_diagnostics.py | 2 +- src/serena/mcp.py | 3 +- src/serena/project.py | 32 +-- src/serena/project_server.py | 5 +- src/serena/repl/facade.py | 10 +- src/serena/tools/query_project_tools.py | 18 +- src/serena/tools/tools_base.py | 20 +- src/serena/util/file_proxy.py | 35 +-- test/serena/config/test_serena_config.py | 46 ++-- test/serena/test_code_editor_atomic_writes.py | 8 + test/serena/test_repl_tool.py | 8 +- 27 files changed, 571 insertions(+), 262 deletions(-) create mode 100644 src/serena/jetbrains/jetbrains_backend.py create mode 100644 src/serena/language_backend.py create mode 100644 src/serena/lsp/lsp_backend.py diff --git a/docs/autogen_docs.py b/docs/autogen_docs.py index 20b03e39..3d613d4a 100644 --- a/docs/autogen_docs.py +++ b/docs/autogen_docs.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 1a4b21eb..b2f6d25d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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. diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py index 02d12929..2ead12a4 100644 --- a/scripts/demo_diagnostics.py +++ b/scripts/demo_diagnostics.py @@ -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), diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py index 001f001d..92107fe7 100644 --- a/scripts/demo_find_defining_symbol.py +++ b/scripts/demo_find_defining_symbol.py @@ -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), diff --git a/scripts/demo_find_implementing_symbol.py b/scripts/demo_find_implementing_symbol.py index 30402f25..87ea6c62 100644 --- a/scripts/demo_find_implementing_symbol.py +++ b/scripts/demo_find_implementing_symbol.py @@ -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), diff --git a/scripts/demo_progressive_tool_shortening.py b/scripts/demo_progressive_tool_shortening.py index 9af742ef..0e06f151 100644 --- a/scripts/demo_progressive_tool_shortening.py +++ b/scripts/demo_progressive_tool_shortening.py @@ -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: diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index d6c61f0e..2dc6efbe 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -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) diff --git a/src/serena/agent.py b/src/serena/agent.py index 943f4543..af29bd93 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -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" diff --git a/src/serena/cli.py b/src/serena/cli.py index e256adbe..29d87331 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -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 diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 59b70b5b..85aa8b96 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -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 diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 86c8ffed..a73c3d49 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -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 diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index dcb3bb2c..117482b4 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -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, diff --git a/src/serena/jetbrains/jetbrains_backend.py b/src/serena/jetbrains/jetbrains_backend.py new file mode 100644 index 00000000..6e62fce9 --- /dev/null +++ b/src/serena/jetbrains/jetbrains_backend.py @@ -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., "") + """ + + 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 diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index 1739e607..5ccd561b 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -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 diff --git a/src/serena/language_backend.py b/src/serena/language_backend.py new file mode 100644 index 00000000..54b98525 --- /dev/null +++ b/src/serena/language_backend.py @@ -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. "") 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()) diff --git a/src/serena/lsp/lsp_backend.py b/src/serena/lsp/lsp_backend.py new file mode 100644 index 00000000..1d672195 --- /dev/null +++ b/src/serena/lsp/lsp_backend.py @@ -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) diff --git a/src/serena/lsp/lsp_diagnostics.py b/src/serena/lsp/lsp_diagnostics.py index 661c3332..4d0a0844 100644 --- a/src/serena/lsp/lsp_diagnostics.py +++ b/src/serena/lsp/lsp_diagnostics.py @@ -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 diff --git a/src/serena/mcp.py b/src/serena/mcp.py index cbe38d8b..a7bee105 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -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 diff --git a/src/serena/project.py b/src/serena/project.py index a6dd1f04..396a01cf 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -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) diff --git a/src/serena/project_server.py b/src/serena/project_server.py index e00100d3..abc2e3c0 100644 --- a/src/serena/project_server.py +++ b/src/serena/project_server.py @@ -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"] = {} diff --git a/src/serena/repl/facade.py b/src/serena/repl/facade.py index cfe53a7c..96828682 100644 --- a/src/serena/repl/facade.py +++ b/src/serena/repl/facade.py @@ -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: diff --git a/src/serena/tools/query_project_tools.py b/src/serena/tools/query_project_tools.py index 68e4eb3e..781ccc27 100644 --- a/src/serena/tools/query_project_tools.py +++ b/src/serena/tools/query_project_tools.py @@ -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() diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index b80413fd..dce69857 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -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: diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py index df6ab3a4..14889a1a 100644 --- a/src/serena/util/file_proxy.py +++ b/src/serena/util/file_proxy.py @@ -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., "") - """ - - 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 diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 9b91bbdb..c0f21d9e 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -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( diff --git a/test/serena/test_code_editor_atomic_writes.py b/test/serena/test_code_editor_atomic_writes.py index e9264eef..77b55237 100644 --- a/test/serena/test_code_editor_atomic_writes.py +++ b/test/serena/test_code_editor_atomic_writes.py @@ -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 diff --git a/test/serena/test_repl_tool.py b/test/serena/test_repl_tool.py index f3f0a5f5..9cf5dd38 100644 --- a/test/serena/test_repl_tool.py +++ b/test/serena/test_repl_tool.py @@ -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):