From e0ab4db1891ca5d03a779c1b3ea0ad947655d4d6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 28 Oct 2025 01:08:20 +0100 Subject: [PATCH] Add LanguageServerManager, adding multi-language support in a single project --- src/serena/agent.py | 42 ++++------- src/serena/cli.py | 3 +- src/serena/code_editor.py | 22 +++--- src/serena/config/serena_config.py | 31 +++++--- src/serena/dashboard.py | 2 +- src/serena/ls_manager.py | 88 +++++++++++++++++++++++ src/serena/project.py | 90 ++++++++++++++++-------- src/serena/symbol.py | 49 +++++++------ src/serena/tools/symbol_tools.py | 2 +- src/serena/tools/tools_base.py | 28 +++++--- src/solidlsp/ls.py | 1 + src/solidlsp/ls_exceptions.py | 12 ++++ src/solidlsp/ls_handler.py | 20 ++++-- test/serena/config/test_serena_config.py | 8 +-- test/serena/test_serena_agent.py | 4 +- test/serena/test_symbol_editing.py | 2 +- 16 files changed, 277 insertions(+), 127 deletions(-) create mode 100644 src/serena/ls_manager.py diff --git a/src/serena/agent.py b/src/serena/agent.py index 8236db55..2cbb5dfa 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -22,12 +22,12 @@ from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import RegisteredContext, SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition, ToolSet from serena.dashboard import SerenaDashboardAPI +from serena.ls_manager import LanguageServerManager from serena.project import Project from serena.prompt_factory import SerenaPromptFactory from serena.tools import ActivateProjectTool, GetCurrentConfigTool, Tool, ToolMarker, ToolRegistry from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler -from solidlsp import SolidLanguageServer if TYPE_CHECKING: from serena.gui_log_viewer import GuiLogViewer @@ -86,7 +86,7 @@ class SerenaAgent: # project-specific instances, which will be initialized upon project activation self._active_project: Project | None = None - self.language_server: SolidLanguageServer | None = None + self.language_server_manager: LanguageServerManager | None = None # adjust log level serena_log_level = self.serena_config.log_level @@ -391,15 +391,14 @@ class SerenaAgent: self._active_project = project self._update_active_tools() - def init_language_server() -> None: + def init_language_server_manager() -> None: # start the language server with LogTime("Language server initialization", logger=log): - self.reset_language_server() - assert self.language_server is not None + self.reset_language_server_manager() # initialize the language server in the background (if in language server mode) if self.is_using_language_server(): - self.issue_task(init_language_server) + self.issue_task(init_language_server_manager) if self._project_activation_callback is not None: self._project_activation_callback() @@ -506,10 +505,7 @@ class SerenaAgent: return result_str - def is_language_server_running(self) -> bool: - return self.language_server is not None and self.language_server.is_running() - - def reset_language_server(self) -> None: + def reset_language_server_manager(self) -> None: """ Starts/resets the language server for the current project """ @@ -522,26 +518,18 @@ class SerenaAgent: ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout # stop the language server if it is running - if self.is_language_server_running(): - assert self.language_server is not None - log.info(f"Stopping the current language server at {self.language_server.repository_root_path} ...") - self.language_server.stop() - self.language_server = None + if self.language_server_manager is not None: + self.language_server_manager.stop_all() + self.language_server_manager = None - # instantiate and start the language server + # instantiate and start the necessarys language servers assert self._active_project is not None - self.language_server = self._active_project.create_language_server( + self.language_server_manager = self._active_project.create_language_server_manager( log_level=self.serena_config.log_level, ls_timeout=ls_timeout, trace_lsp_communication=self.serena_config.trace_lsp_communication, ls_specific_settings=self.serena_config.ls_specific_settings, ) - log.info(f"Starting the language server for {self._active_project.project_name}") - self.language_server.start() - if not self.language_server.is_running(): - raise RuntimeError( - f"Failed to start the language server for {self._active_project.project_name} at {self._active_project.project_root}" - ) def get_tool(self, tool_class: type[TTool]) -> TTool: return self._all_tools[tool_class] # type: ignore @@ -556,11 +544,9 @@ class SerenaAgent: if not hasattr(self, "_is_initialized"): return log.info("SerenaAgent is shutting down ...") - if self.is_language_server_running(): - log.info("Stopping the language server ...") - assert self.language_server is not None - self.language_server.save_cache() - self.language_server.stop() + if self.language_server_manager is not None: + self.language_server_manager.stop_all(save_cache=True) + self.language_server_manager = None if self._gui_log_viewer: log.info("Stopping the GUI log window ...") self._gui_log_viewer.stop() diff --git a/src/serena/cli.py b/src/serena/cli.py index 0db76046..a5a7e2d6 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -429,7 +429,7 @@ class ProjectCommands(AutoRegisteringGroup): all_langs = [l.name.lower() for l in Language.iter_all(include_experimental=True)] raise ValueError(f"Unknown language '{language}'. Supported: {all_langs}") generated_conf = ProjectConfig.autogenerate(project_root=project_path, project_language=lang_inst) - print(f"Generated project.yml with language {generated_conf.language.value} at {yml_path}.") + print(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.") @staticmethod @click.command("index", help="Index a project by saving symbols to the LSP cache.") @@ -460,6 +460,7 @@ class ProjectCommands(AutoRegisteringGroup): serena_config = SerenaConfig.from_config_file() proj = Project.load(os.path.abspath(project)) click.echo(f"Indexing symbols in project {project}…") + # TODO FIXME ls = proj.create_language_server(log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings) log_file = os.path.join(project, ".serena", "logs", "indexing.txt") diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 6fb1e668..6e9f65b1 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -231,12 +231,11 @@ class CodeEditor(Generic[TSymbol], ABC): class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): def __init__(self, symbol_retriever: LanguageServerSymbolRetriever, agent: Optional["SerenaAgent"] = None): - super().__init__(project_root=symbol_retriever.get_language_server().repository_root_path, agent=agent) + super().__init__(project_root=symbol_retriever.get_root_path(), agent=agent) self._symbol_retriever = symbol_retriever - @property - def _lang_server(self) -> SolidLanguageServer: - return self._symbol_retriever.get_language_server() + def _get_language_server(self, relative_path: str) -> SolidLanguageServer: + return self._symbol_retriever.get_language_server(relative_path) class EditedFile(CodeEditor.EditedFile): def __init__(self, lang_server: SolidLanguageServer, relative_path: str, file_buffer: LSPFileBuffer): @@ -258,12 +257,14 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): @contextmanager def _open_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: - with self._lang_server.open_file(relative_path) as file_buffer: - yield self.EditedFile(self._lang_server, relative_path, file_buffer) + lang_server = self._get_language_server(relative_path) + with lang_server.open_file(relative_path) as file_buffer: + yield self.EditedFile(lang_server, relative_path, file_buffer) def _get_code_file_content(self, relative_path: str) -> str: """Get the content of a file using the language server.""" - return self._lang_server.language_server.retrieve_full_file_content(relative_path) + lang_server = self._get_language_server(relative_path) + return lang_server.language_server.retrieve_full_file_content(relative_path) def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> LanguageServerSymbol: symbol_candidates = self._symbol_retriever.find_by_name(name_path, within_relative_path=relative_file_path) @@ -289,7 +290,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): # Handle the 'changes' format (URI -> list of TextEdits) for uri, edits in uri_to_edits.items(): file_path = PathUtils.uri_to_path(uri) - relative_path = os.path.relpath(file_path, self._lang_server.repository_root_path) + relative_path = os.path.relpath(file_path, self._symbol_retriever.get_root_path()) modified_relative_paths.append(relative_path) with self._edited_file_context(relative_path) as edited_file: edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file) @@ -305,12 +306,13 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): assert symbol.location.line is not None assert symbol.location.column is not None - rename_result = self._lang_server.request_rename_symbol_edit( + lang_server = self._get_language_server(relative_file_path) + rename_result = lang_server.request_rename_symbol_edit( relative_file_path=relative_file_path, line=symbol.location.line, column=symbol.location.column, new_name=new_name ) if rename_result is None: raise ValueError( - f"Language server for {self._lang_server.language_id} returned no rename edits for symbol '{name_path}'. " + f"Language server for {lang_server.language_id} returned no rename edits for symbol '{name_path}'. " f"The symbol might not support renaming." ) modified_files = self._apply_workspace_edit(rename_result) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index c462d561..930a820b 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -162,7 +162,7 @@ def is_running_in_docker() -> bool: @dataclass(kw_only=True) class ProjectConfig(ToolInclusionDefinition, ToStringMixin): project_name: str - language: Language + languages: list[Language] ignored_paths: list[str] = field(default_factory=list) read_only: bool = False ignore_all_files_in_gitignore: bool = True @@ -212,7 +212,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): dominant_language = project_language.value config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True) config_with_comments["project_name"] = project_name - config_with_comments["language"] = dominant_language + config_with_comments["languages"] = [dominant_language] if save_to_disk: save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True) return cls._from_dict(config_with_comments) @@ -226,19 +226,28 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): """ Create a ProjectConfig instance from a configuration dictionary """ - language_str = data["language"].lower() project_name = data["project_name"] + language_strings = data.get("languages", []) + # backwards compatibility - if language_str == "javascript": - log.warning(f"Found deprecated project language `javascript` in project {project_name}, please change to `typescript`") - language_str = "typescript" - try: - language = Language(language_str) - except ValueError as e: - raise ValueError(f"Invalid language: {data['language']}.\nValid languages are: {[l.value for l in Language]}") from e + lang_name_mapping = {"javascript": "typescript"} + if len(language_strings) == 0 and "language" in data: + language_strings = [data["language"]] + + languages: list[Language] = [] + for language_str in language_strings: + try: + language_str = language_str.lower() + if language_str in lang_name_mapping: + language_str = lang_name_mapping[language_str] + language = Language(language_str) + languages.append(language) + except ValueError as e: + raise ValueError(f"Invalid language: {data['language']}.\nValid language_strings are: {[l.value for l in Language]}") from e + return cls( project_name=project_name, - language=language, + languages=languages, ignored_paths=data.get("ignored_paths", []), excluded_tools=data.get("excluded_tools", []), included_optional_tools=data.get("included_optional_tools", []), diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 0468e0fa..b2a64f5e 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -154,7 +154,7 @@ class SerenaDashboardAPI: active_project_name = project.project_name if project else None project_info = { "name": active_project_name, - "language": project.project_config.language.value if project else None, + "language": ", ".join([l.value for l in project.project_config.languages]) if project else None, } # Get context info diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py new file mode 100644 index 00000000..c0d228e5 --- /dev/null +++ b/src/serena/ls_manager.py @@ -0,0 +1,88 @@ +import logging +from collections.abc import Callable, Iterator + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +log = logging.getLogger(__name__) + + +class LanguageServerManager: + """ + Manages one or more language servers + """ + + def __init__( + self, + language_servers: dict[Language, SolidLanguageServer], + language_server_factories: dict[Language, Callable[[], SolidLanguageServer]] | None = None, + ) -> None: + """ + :param language_servers: a mapping from language to language server; the servers are assumed to be already started. + The first server in the iteration order is used as the default server. + All servers are assumed to serve the same project root. + :param language_server_factories: factories for the language servers, to be used for restarting them if needed + """ + self._language_servers = language_servers + self._language_server_factories = language_server_factories or {} + self._default_language_server = next(iter(language_servers.values())) + self._root_path = self._default_language_server.repository_root_path + + def get_root_path(self) -> str: + return self._root_path + + def _ensure_functional_ls(self, ls: SolidLanguageServer) -> SolidLanguageServer: + if not ls.is_running(): + log.warning(f"Language server for language {ls.language} is not running; restarting ...") + ls = self.restart_language_server(ls.language) + return ls + + def get_language_server(self, relative_path: str) -> SolidLanguageServer: + ls: SolidLanguageServer | None = None + if len(self._language_servers) > 1: + for candidate in self._language_servers.values(): + if not candidate.is_ignored_path(relative_path, ignore_unsupported_files=True): + ls = candidate + break + if ls is None: + ls = self._default_language_server + return self._ensure_functional_ls(ls) + + def restart_language_server(self, language: Language) -> SolidLanguageServer: + """ + Forces recreation and restart of the language server for the given language. + + :param language: the language + :return: the newly created language server + """ + factory = self._language_server_factories.get(language) + if factory is None: + raise ValueError(f"No language server factory found for language {language}; cannot recreate and restart") + self._language_servers[language] = factory() + self._language_servers[language].start() + return self._language_servers[language] + + def iter_language_servers(self) -> Iterator[SolidLanguageServer]: + for ls in self._language_servers.values(): + yield self._ensure_functional_ls(ls) + + def stop_all(self, save_cache: bool = False) -> None: + """ + Stops all managed language servers. + + :param save_cache: whether to save the cache before stopping + """ + for ls in self.iter_language_servers(): + if ls.is_running(): + if save_cache: + ls.save_cache() + log.info(f"Stopping language server for language {ls.language} ...") + ls.stop() + + def save_all_caches(self) -> None: + """ + Saves the caches of all managed language servers. + """ + for ls in self.iter_language_servers(): + if ls.is_running(): + ls.save_cache() diff --git a/src/serena/project.py b/src/serena/project.py index e96266cf..f36e0760 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -1,6 +1,7 @@ import json import logging import os +from collections.abc import Callable from pathlib import Path from typing import Any @@ -9,6 +10,7 @@ from sensai.util.string import ToStringMixin from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig, get_serena_managed_in_project_dir from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME +from serena.ls_manager import LanguageServerManager from serena.text_utils import MatchedConsecutiveLines, search_files from serena.util.file_system import GitignoreParser, match_path from solidlsp import SolidLanguageServer @@ -101,10 +103,6 @@ class Project(ToStringMixin): def project_name(self) -> str: return self.project_config.project_name - @property - def language(self) -> Language: - return self.project_config.language - @classmethod def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project": project_root = Path(project_root).resolve() @@ -127,7 +125,8 @@ class Project(ToStringMixin): msg = f"Created and activated a new project with name '{self.project_name}' at {self.project_root}. " else: msg = f"The project with name '{self.project_name}' at {self.project_root} is activated." - msg += f"\nProgramming language: {self.project_config.language.value}; file encoding: {self.project_config.encoding}" + languages_str = ", ".join([lang.value for lang in self.project_config.languages]) + msg += f"\nProgramming languages: {languages_str}; file encoding: {self.project_config.encoding}" memories = self.memories_manager.list_memories() if memories: msg += ( @@ -179,8 +178,13 @@ class Project(ToStringMixin): # Check file extension if it's a file is_file = os.path.isfile(abs_path) if is_file and ignore_non_source_files: - fn_matcher = self.language.get_source_fn_matcher() - if not fn_matcher.is_relevant_filename(abs_path): + is_file_in_supported_language = False + for language in self.project_config.languages: + 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 # Create normalized path for consistent handling @@ -345,13 +349,46 @@ class Project(ToStringMixin): source_file_path=relative_file_path, ) - def create_language_server( + def _create_language_server_factory( + self, + language: Language, + ls_timeout: float | None, + ls_specific_settings: dict | None, + log_level: int, + trace_lsp_communication: bool, + ) -> Callable[[], SolidLanguageServer]: + + def factory() -> SolidLanguageServer: + ls_config = LanguageServerConfig( + code_language=language, + ignored_paths=self._ignored_patterns, + trace_lsp_communication=trace_lsp_communication, + encoding=self.project_config.encoding, + ) + ls_logger = LanguageServerLogger(log_level=log_level) + + log.info(f"Creating language server instance for {self.project_root}, language={language}.") + return SolidLanguageServer.create( + ls_config, + ls_logger, + self.project_root, + timeout=ls_timeout, + solidlsp_settings=SolidLSPSettings( + solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, + project_data_relative_path=SERENA_MANAGED_DIR_NAME, + ls_specific_settings=ls_specific_settings or {}, + ), + ) + + return factory + + def create_language_server_manager( self, log_level: int = logging.INFO, ls_timeout: float | None = DEFAULT_TOOL_TIMEOUT - 5, trace_lsp_communication: bool = False, ls_specific_settings: dict[Language, Any] | None = None, - ) -> SolidLanguageServer: + ) -> LanguageServerManager: """ Create a language server for a project. Note that you will have to start it before performing any LS operations. @@ -365,23 +402,18 @@ class Project(ToStringMixin): see docstrings in the inits of subclasses of SolidLanguageServer to see what values may be passed. :return: the language server """ - ls_config = LanguageServerConfig( - code_language=self.language, - ignored_paths=self._ignored_patterns, - trace_lsp_communication=trace_lsp_communication, - encoding=self.project_config.encoding, - ) - ls_logger = LanguageServerLogger(log_level=log_level) - - log.info(f"Creating language server instance for {self.project_root}.") - return SolidLanguageServer.create( - ls_config, - ls_logger, - self.project_root, - timeout=ls_timeout, - solidlsp_settings=SolidLSPSettings( - solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, - project_data_relative_path=SERENA_MANAGED_DIR_NAME, - ls_specific_settings=ls_specific_settings or {}, - ), - ) + log.info(f"Creating language server manager for {self.project_root}") + language_servers: dict[Language, SolidLanguageServer] = {} + language_server_factories: dict[Language, Callable[[], SolidLanguageServer]] = {} + for language in self.project_config.languages: + language_server_factory = self._create_language_server_factory( + language, ls_timeout, ls_specific_settings, log_level, trace_lsp_communication + ) + language_server = language_server_factory() + log.info(f"Starting language server for {language} at root {self.project_root}") + language_server.start() + if not language_server.is_running(): + raise RuntimeError(f"Failed to start the language server for {language} for root {self.project_root}") + language_servers[language] = language_server + language_server_factories[language] = language_server_factory + return LanguageServerManager(language_servers, language_server_factories) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 134b217e..e97b6c77 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -12,6 +12,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation +from .ls_manager import LanguageServerManager from .project import Project if TYPE_CHECKING: @@ -441,24 +442,25 @@ class ReferenceInLanguageServerSymbol(ToStringMixin): class LanguageServerSymbolRetriever: - def __init__(self, lang_server: SolidLanguageServer, agent: Union["SerenaAgent", None] = None) -> None: + def __init__(self, ls: SolidLanguageServer | LanguageServerManager, agent: Union["SerenaAgent", None] = None) -> None: """ - :param lang_server: the language server to use for symbol retrieval as well as editing operations. + :param ls: the language server or language server manager to use for symbol retrieval and editing operations. :param agent: the agent to use (only needed for marking files as modified). You can pass None if you don't need an agent to be aware of file modifications performed by the symbol manager. """ - self._lang_server = lang_server + if isinstance(ls, SolidLanguageServer): + ls_manager = LanguageServerManager({ls.language: ls}) + else: + ls_manager = ls + assert isinstance(ls_manager, LanguageServerManager) + self._ls_manager: LanguageServerManager = ls_manager self.agent = agent - def set_language_server(self, lang_server: SolidLanguageServer) -> None: - """ - Set the language server to use for symbol retrieval and editing operations. - This is useful if you want to change the language server after initializing the SymbolManager. - """ - self._lang_server = lang_server + def get_root_path(self) -> str: + return self._ls_manager.get_root_path() - def get_language_server(self) -> SolidLanguageServer: - return self._lang_server + def get_language_server(self, relative_path: str) -> SolidLanguageServer: + return self._ls_manager.get_language_server(relative_path) def find_by_name( self, @@ -475,24 +477,27 @@ class LanguageServerSymbolRetriever: to symbols within a specific file or directory. """ symbols: list[LanguageServerSymbol] = [] - symbol_roots = self._lang_server.request_full_symbol_tree(within_relative_path=within_relative_path, include_body=include_body) - for root in symbol_roots: - symbols.extend( - LanguageServerSymbol(root).find( - name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching + for lang_server in self._ls_manager.iter_language_servers(): + symbol_roots = lang_server.request_full_symbol_tree(within_relative_path=within_relative_path, include_body=include_body) + for root in symbol_roots: + symbols.extend( + LanguageServerSymbol(root).find( + name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching + ) ) - ) return symbols def get_document_symbols(self, relative_path: str) -> list[LanguageServerSymbol]: - symbol_dicts, _roots = self._lang_server.request_document_symbols(relative_path, include_body=False) + lang_server = self.get_language_server(relative_path) + symbol_dicts, _roots = lang_server.request_document_symbols(relative_path, include_body=False) symbols = [LanguageServerSymbol(s) for s in symbol_dicts] return symbols def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None: if location.relative_path is None: return None - symbol_dicts, _roots = self._lang_server.request_document_symbols(location.relative_path, include_body=False) + lang_server = self.get_language_server(location.relative_path) + symbol_dicts, _roots = lang_server.request_document_symbols(location.relative_path, include_body=False) for symbol_dict in symbol_dicts: symbol = LanguageServerSymbol(symbol_dict) if symbol.location == location: @@ -561,7 +566,8 @@ class LanguageServerSymbolRetriever: assert symbol_location.relative_path is not None assert symbol_location.line is not None assert symbol_location.column is not None - references = self._lang_server.request_referencing_symbols( + lang_server = self.get_language_server(symbol_location.relative_path) + references = lang_server.request_referencing_symbols( relative_file_path=symbol_location.relative_path, line=symbol_location.line, column=symbol_location.column, @@ -589,7 +595,8 @@ class LanguageServerSymbolRetriever: return cls(name_path=symbol.get_name_path(), kind=int(symbol.symbol_kind)) def get_symbol_overview(self, relative_path: str) -> dict[str, list[SymbolOverviewElement]]: - path_to_unified_symbols = self._lang_server.request_overview(relative_path) + lang_server = self.get_language_server(relative_path) + path_to_unified_symbols = lang_server.request_overview(relative_path) result = {} for file_path, unified_symbols in path_to_unified_symbols.items(): # TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 1f8cbf58..bd31fc48 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -42,7 +42,7 @@ class RestartLanguageServerTool(Tool, ToolMarkerOptional): """Use this tool only on explicit user request or after confirmation. It may be necessary to restart the language server if it hangs. """ - self.agent.reset_language_server() + self.agent.reset_language_server_manager() return SUCCESS_RESULT diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 58e70ea2..5fe58675 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -47,9 +47,9 @@ class Component(ABC): def create_language_server_symbol_retriever(self) -> LanguageServerSymbolRetriever: if not self.agent.is_using_language_server(): raise Exception("Cannot create LanguageServerSymbolRetriever; agent is not in language server mode.") - language_server = self.agent.language_server - assert language_server is not None - return LanguageServerSymbolRetriever(language_server, agent=self.agent) + language_server_manager = self.agent.language_server_manager + assert language_server_manager is not None + return LanguageServerSymbolRetriever(language_server_manager, agent=self.agent) @property def project(self) -> Project: @@ -244,18 +244,24 @@ class Tool(Component): "Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: " + f"{self.agent.serena_config.project_names}" ) - if self.agent.is_using_language_server() and not self.agent.is_language_server_running(): - log.info("Language server is not running. Starting it ...") - self.agent.reset_language_server() # apply the actual tool try: result = apply_fn(**kwargs) except SolidLSPException as e: if e.is_language_server_terminated(): - log.error(f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ...") - self.agent.reset_language_server() - result = apply_fn(**kwargs) + affected_language = e.get_affected_language() + if affected_language is not None and self.agent.language_server_manager is not None: + log.error( + f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." + ) + self.agent.language_server_manager.restart_language_server(affected_language) + result = apply_fn(**kwargs) + else: + log.error( + f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying." + ) + raise else: raise @@ -273,8 +279,8 @@ class Tool(Component): log.info(f"Result: {result}") try: - if self.agent.language_server is not None: - self.agent.language_server.save_cache() + if self.agent.language_server_manager is not None: + self.agent.language_server_manager.save_all_caches() except Exception as e: log.error(f"Error saving language server cache: {e}") diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 849b1256..c144c2aa 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -214,6 +214,7 @@ class SolidLanguageServer(ABC): ) self.server = SolidLanguageServerHandler( process_launch_info, + language=self.language, logger=logging_fn, start_independent_lsp_process=config.start_independent_lsp_process, ) diff --git a/src/solidlsp/ls_exceptions.py b/src/solidlsp/ls_exceptions.py index 7c03cfe5..61751eff 100644 --- a/src/solidlsp/ls_exceptions.py +++ b/src/solidlsp/ls_exceptions.py @@ -2,6 +2,8 @@ This module contains the exceptions raised by the framework. """ +from solidlsp.ls_config import Language + class SolidLSPException(Exception): def __init__(self, message: str, cause: Exception | None = None): @@ -26,6 +28,16 @@ class SolidLSPException(Exception): return isinstance(self.cause, LanguageServerTerminatedException) + def get_affected_language(self) -> Language | None: + """ + :return: the affected language for the case where the exception is caused by the language server having terminated + """ + from .ls_handler import LanguageServerTerminatedException + + if isinstance(self.cause, LanguageServerTerminatedException): + return self.cause.language + return None + def __str__(self) -> str: """ Returns a string representation of the exception. diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_handler.py index 02363aaf..9be3f782 100644 --- a/src/solidlsp/ls_handler.py +++ b/src/solidlsp/ls_handler.py @@ -14,6 +14,7 @@ from typing import Any import psutil from sensai.util.string import ToStringMixin +from solidlsp.ls_config import Language from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_request import LanguageServerRequest from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification @@ -42,9 +43,10 @@ class LanguageServerTerminatedException(Exception): Exception raised when the language server process has terminated unexpectedly. """ - def __init__(self, message: str, cause: Exception | None = None) -> None: + def __init__(self, message: str, language: Language, cause: Exception | None = None) -> None: super().__init__(message) self.message = message + self.language = language self.cause = cause def __str__(self) -> str: @@ -132,10 +134,12 @@ class SolidLanguageServerHandler: def __init__( self, process_launch_info: ProcessLaunchInfo, + language: Language, logger: Callable[[str, str, StringDict | str], None] | None = None, start_independent_lsp_process=True, request_timeout: float | None = None, ) -> None: + self.language = language self.send = LanguageServerRequest(self) self.notify = LspNotification(self.send_notification) @@ -310,8 +314,7 @@ class SolidLanguageServerHandler: if self.logger is not None: self.logger("client", "logger", message) - @staticmethod - def _read_bytes_from_process(process, stream, num_bytes): + def _read_bytes_from_process(self, process, stream, num_bytes): """Read exactly num_bytes from process stdout""" data = b"" while len(data) < num_bytes: @@ -319,7 +322,8 @@ class SolidLanguageServerHandler: if not chunk: if process.poll() is not None: raise LanguageServerTerminatedException( - f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)" + f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)", + language=self.language, ) # Process still running but no data available yet, retry after a short delay time.sleep(0.01) @@ -356,13 +360,15 @@ class SolidLanguageServerHandler: except LanguageServerTerminatedException as e: exception = e except (BrokenPipeError, ConnectionResetError) as e: - exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", cause=e) + exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", self.language, cause=e) except Exception as e: - exception = LanguageServerTerminatedException("Unexpected error while reading stdout from language server process", cause=e) + exception = LanguageServerTerminatedException( + "Unexpected error while reading stdout from language server process", self.language, cause=e + ) log.info("Language server stdout reader thread has terminated") if not self._is_shutting_down: if exception is None: - exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly") + exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly", self.language) log.error(str(exception)) self._cancel_pending_requests(exception) diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 14eb3cd9..8c2329fe 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -50,7 +50,7 @@ class TestProjectConfigAutogenerate: # Verify the configuration assert config.project_name == self.project_path.name - assert config.language == Language.PYTHON + assert config.languages == [Language.PYTHON] def test_autogenerate_with_multiple_languages(self): """Test autogeneration picks dominant language when multiple are present.""" @@ -62,7 +62,7 @@ class TestProjectConfigAutogenerate: # Run autogenerate - should pick Python as dominant config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False) - assert config.language == Language.PYTHON + assert config.languages == [Language.PYTHON] def test_autogenerate_saves_to_disk(self): """Test that autogenerate can save the configuration to disk.""" @@ -78,7 +78,7 @@ class TestProjectConfigAutogenerate: assert config_path.exists() # Verify the content - assert config.language == Language.GO + assert config.languages == [Language.GO] def test_autogenerate_nonexistent_path(self): """Test that autogenerate raises FileNotFoundError for non-existent path.""" @@ -115,7 +115,7 @@ class TestProjectConfigAutogenerate: config = ProjectConfig.autogenerate(self.project_path, project_name=custom_name, save_to_disk=False) assert config.project_name == custom_name - assert config.language == Language.TYPESCRIPT + assert config.languages == [Language.TYPESCRIPT] def test_autogenerate_error_message_format(self): """Test the specific format of the error message for better user experience.""" diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 4f067ffb..882b45be 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -37,7 +37,7 @@ def serena_config(): project_root=str(repo_path), project_config=ProjectConfig( project_name=project_name, - language=language, + languages=[language], ignored_paths=[], excluded_tools=set(), read_only=False, @@ -248,7 +248,7 @@ class TestSerenaAgent: and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"] for s in symbols - ), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent._active_project.language.name}. Symbols: {symbols}" + ), f"Expected to find {name_path} ({expected_kind}) in {expected_file}. Symbols: {symbols}" @pytest.mark.parametrize( "serena_agent,name_path", diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py index ee97149a..6a56a30c 100644 --- a/test/serena/test_symbol_editing.py +++ b/test/serena/test_symbol_editing.py @@ -205,7 +205,7 @@ class EditingTest(ABC): log.info(f"Starting language server for {self.language} {self.rel_path}") language_server.start() log.info(f"Language server started for {self.language} {self.rel_path}") - yield LanguageServerSymbolRetriever(lang_server=language_server) + yield LanguageServerSymbolRetriever(ls=language_server) finally: if language_server is not None and language_server.is_running(): log.info(f"Stopping language server for {self.language} {self.rel_path}")