diff --git a/src/serena/agent.py b/src/serena/agent.py index 8236db55..53ed791b 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -22,12 +22,13 @@ 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 +from solidlsp.ls_config import Language if TYPE_CHECKING: from serena.gui_log_viewer import GuiLogViewer @@ -86,7 +87,6 @@ class SerenaAgent: # project-specific instances, which will be initialized upon project activation self._active_project: Project | None = None - self.language_server: SolidLanguageServer | None = None # adjust log level serena_log_level = self.serena_config.log_level @@ -192,6 +192,21 @@ class SerenaAgent: process.start() process.join(timeout=1) + def get_language_server_manager(self) -> LanguageServerManager | None: + if self._active_project is not None: + return self._active_project.language_server_manager + return None + + def get_language_server_manager_or_raise(self) -> LanguageServerManager: + language_server_manager = self.get_language_server_manager() + if language_server_manager is None: + raise Exception( + "The language server manager is not initialized, indicating a problem during project activation. " + "Inform the user, telling them to inspect Serena's logs in order to determine the issue. " + "IMPORTANT: Wait for further instructions before you continue!" + ) + return language_server_manager + def get_context(self) -> SerenaAgentContext: return self._context @@ -369,7 +384,7 @@ class SerenaAgent: log.info(f"Scheduling {task_name}") return self._task_executor.submit(task_execution_wrapper) - def execute_task(self, task: Callable[[], T]) -> T: + def execute_task(self, task: Callable[[], T], name: str | None = None) -> T: """ Executes the given task synchronously via the agent's task executor. This is useful for tasks that need to be executed immediately and whose results are needed right away. @@ -377,7 +392,7 @@ class SerenaAgent: :param task: the task to execute :return: the result of the task execution """ - future = self.issue_task(task) + future = self.issue_task(task, name=name) return future.result() def is_using_language_server(self) -> bool: @@ -391,15 +406,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,12 +520,9 @@ 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 + Starts/resets the language server manager for the current project """ tool_timeout = self.serena_config.tool_timeout if tool_timeout is None or tool_timeout < 0: @@ -521,27 +532,23 @@ class SerenaAgent: raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds") 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 - - # instantiate and start the language server - assert self._active_project is not None - self.language_server = self._active_project.create_language_server( + # instantiate and start the necessary language servers + self.get_active_project_or_raise().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 add_language(self, language: Language) -> None: + """ + Adds a new language to the active project, spawning the respective language server and updating the project configuration. + The addition is scheduled via the agent's task executor and executed synchronously, i.e. the method returns + when the addition is complete. + + :param language: the language to add + """ + self.execute_task(lambda: self.get_active_project_or_raise().add_language(language), name=f"AddLanguage:{language.value}") def get_tool(self, tool_class: type[TTool]) -> TTool: return self._all_tools[tool_class] # type: ignore @@ -556,11 +563,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._active_project is not None: + self._active_project.shutdown() + self._active_project = 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..fb78cd3d 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -416,20 +416,23 @@ class ProjectCommands(AutoRegisteringGroup): @staticmethod @click.command("generate-yml", help="Generate a project.yml file.") @click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd()) - @click.option("--language", type=str, default=None, help="Programming language; inferred if not specified.") - def generate_yml(project_path: str, language: str | None = None) -> None: + @click.option( + "--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times." + ) + def generate_yml(project_path: str, language: tuple[str, ...]) -> None: yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml()) if os.path.exists(yml_path): raise FileExistsError(f"Project file {yml_path} already exists.") - lang_inst = None + languages: list[Language] = [] if language: - try: - lang_inst = Language[language.upper()] - except KeyError: - 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}.") + for lang in language: + try: + languages.append(Language(lang.lower())) + except ValueError: + all_langs = [l.value for l in Language] + raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}") + generated_conf = ProjectConfig.autogenerate(project_root=project_path, languages=languages if languages else None) + 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.") @@ -459,33 +462,42 @@ class ProjectCommands(AutoRegisteringGroup): logging.configure(level=lvl) serena_config = SerenaConfig.from_config_file() proj = Project.load(os.path.abspath(project)) - click.echo(f"Indexing symbols in project {project}…") - 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") + click.echo(f"Indexing symbols in project {project} …") + ls_mgr = proj.create_language_server_manager( + log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings + ) + try: + log_file = os.path.join(project, ".serena", "logs", "indexing.txt") - collected_exceptions: list[Exception] = [] - files_failed = [] - with ls.start_server(): files = proj.gather_source_files() - for i, f in enumerate(tqdm(files, desc="Indexing")): - try: - ls.request_document_symbols(f, include_body=False) - ls.request_document_symbols(f, include_body=True) - except Exception as e: - log.error(f"Failed to index {f}, continuing.") - collected_exceptions.append(e) - files_failed.append(f) - if (i + 1) % 10 == 0: - ls.save_cache() - ls.save_cache() - click.echo(f"Symbols saved to {ls.cache_path}") - if len(files_failed) > 0: - os.makedirs(os.path.dirname(log_file), exist_ok=True) - with open(log_file, "w") as f: - for file, exception in zip(files_failed, collected_exceptions, strict=True): - f.write(f"{file}\n") - f.write(f"{exception}\n") - click.echo(f"Failed to index {len(files_failed)} files, see:\n{log_file}") + + servers = list(ls_mgr.iter_language_servers()) + for k, ls in enumerate(servers, start=1): + click.echo(f"Indexing for language {ls.language.value} ({k}/{len(servers)}) …") + collected_exceptions: list[Exception] = [] + files_failed = [] + for i, f in enumerate(tqdm(files, desc="Indexing")): + try: + ls.request_document_symbols(f, include_body=False) + ls.request_document_symbols(f, include_body=True) + except Exception as e: + log.error(f"Failed to index {f}, continuing.") + collected_exceptions.append(e) + files_failed.append(f) + if (i + 1) % 10 == 0: + ls.save_cache() + ls.save_cache() + click.echo(f"Symbols saved to {ls.cache_path}") + + if len(files_failed) > 0: + os.makedirs(os.path.dirname(log_file), exist_ok=True) + with open(log_file, "w") as f: + for file, exception in zip(files_failed, collected_exceptions, strict=True): + f.write(f"{file}\n") + f.write(f"{exception}\n") + click.echo(f"Failed to index {len(files_failed)} files, see:\n{log_file}") + finally: + ls_mgr.stop_all() @staticmethod @click.command("is_ignored_path", help="Check if a path is ignored by the project configuration.") @@ -522,16 +534,20 @@ class ProjectCommands(AutoRegisteringGroup): if proj.is_ignored_path(file, ignore_non_source_files=True): click.echo(f"'{file}' is ignored or declared as non-code file by the project configuration, won't index.") exit(1) - ls = proj.create_language_server() - with ls.start_server(): - symbols, _ = ls.request_document_symbols(file, include_body=False) - ls.request_document_symbols(file, include_body=True) - if verbose: - click.echo(f"Symbols in file '{file}':") - for symbol in symbols: - click.echo(f" - {symbol['name']} at line {symbol['selectionRange']['start']['line']} of kind {symbol['kind']}") - ls.save_cache() - click.echo(f"Successfully indexed file '{file}', {len(symbols)} symbols saved to {ls.cache_path}.") + ls_mgr = proj.create_language_server_manager() + try: + for ls in ls_mgr.iter_language_servers(): + click.echo(f"Indexing for language {ls.language.value} …") + symbols, _ = ls.request_document_symbols(file, include_body=False) + ls.request_document_symbols(file, include_body=True) + if verbose: + click.echo(f"Symbols in file '{file}':") + for symbol in symbols: + click.echo(f" - {symbol['name']} at line {symbol['selectionRange']['start']['line']} of kind {symbol['kind']}") + ls.save_cache() + click.echo(f"Successfully indexed file '{file}', {len(symbols)} symbols saved to {ls.cache_path}.") + finally: + ls_mgr.stop_all() @staticmethod @click.command("health-check", help="Perform a comprehensive health check of the project's tools and language server.") 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..cbdbf6d4 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -2,6 +2,7 @@ The Serena Model Context Protocol (MCP) Server """ +import dataclasses import os import shutil from collections.abc import Iterable @@ -162,7 +163,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 @@ -176,7 +177,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): @classmethod def autogenerate( - cls, project_root: str | Path, project_name: str | None = None, project_language: Language | None = None, save_to_disk: bool = True + cls, project_root: str | Path, project_name: str | None = None, languages: list[Language] | None = None, save_to_disk: bool = True ) -> Self: """ Autogenerate a project configuration for a given project root. @@ -184,7 +185,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): :param project_root: the path to the project root :param project_name: the name of the project; if None, the name of the project will be the name of the directory containing the project - :param project_language: the programming language of the project; if None, it will be determined automatically + :param languages: the languages of the project; if None, they will be determined automatically :param save_to_disk: whether to save the project configuration to disk :return: the project configuration """ @@ -193,7 +194,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): raise FileNotFoundError(f"Project root not found: {project_root}") with LogTime("Project configuration auto-generation", logger=log): project_name = project_name or project_root.name - if project_language is None: + if languages is None: language_composition = determine_programming_language_composition(str(project_root)) if len(language_composition) == 0: raise ValueError( @@ -208,11 +209,12 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): ) # find the language with the highest percentage dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) + languages_to_use = [dominant_language] else: - dominant_language = project_language.value + languages_to_use = [lang.value for lang in languages] 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"] = languages_to_use 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 +228,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", []), @@ -248,6 +259,14 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): encoding=data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING), ) + def to_yaml_dict(self) -> dict: + """ + :return: a yaml-serializable dictionary representation of this configuration + """ + d = dataclasses.asdict(self) + d["languages"] = [lang.value for lang in self.languages] + return d + @classmethod def load(cls, project_root: Path | str, autogenerate: bool = False) -> Self: """ diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 6b38b38b..d59ff8f1 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -155,7 +155,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, "path": str(project.project_root) if project else None, } diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py new file mode 100644 index 00000000..c5dcdbd7 --- /dev/null +++ b/src/serena/ls_manager.py @@ -0,0 +1,201 @@ +import logging +import threading +from collections.abc import Iterator + +from sensai.util.logging import LogTime + +from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class LanguageServerFactory: + def __init__( + self, + project_root: str, + encoding: str, + ignored_patterns: list[str], + ls_timeout: float | None = None, + ls_specific_settings: dict | None = None, + log_level: int = logging.INFO, + trace_lsp_communication: bool = False, + ): + self.project_root = project_root + self.encoding = encoding + self.ignored_patterns = ignored_patterns + self.ls_timeout = ls_timeout + self.ls_specific_settings = ls_specific_settings + self.log_level = log_level + self.trace_lsp_communication = trace_lsp_communication + + def create_language_server(self, language: Language) -> SolidLanguageServer: + ls_config = LanguageServerConfig( + code_language=language, + ignored_paths=self.ignored_patterns, + trace_lsp_communication=self.trace_lsp_communication, + encoding=self.encoding, + ) + ls_logger = LanguageServerLogger(log_level=self.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=self.ls_timeout, + solidlsp_settings=SolidLSPSettings( + solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, + project_data_relative_path=SERENA_MANAGED_DIR_NAME, + ls_specific_settings=self.ls_specific_settings or {}, + ), + ) + + +class LanguageServerManager: + """ + Manages one or more language servers for a project. + """ + + def __init__( + self, + language_servers: dict[Language, SolidLanguageServer], + language_server_factory: LanguageServerFactory | 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_factory: factory for language server creation; if None, dynamic (re)creation of language servers + is not supported + """ + self._language_servers = language_servers + self._language_server_factory = language_server_factory + self._default_language_server = next(iter(language_servers.values())) + self._root_path = self._default_language_server.repository_root_path + + @staticmethod + def from_languages(languages: list[Language], factory: LanguageServerFactory) -> "LanguageServerManager": + """ + Creates a manager with language servers for the given languages using the given factory. + The language servers are started in parallel threads. + + :param languages: the languages for which to spawn language servers + :param factory: the factory for language server creation + :return: the instance + """ + language_servers: dict[Language, SolidLanguageServer] = {} + threads = [] + exceptions = {} + lock = threading.Lock() + + def start_language_server(language: Language) -> None: + try: + with LogTime(f"Language server startup (language={language.value})"): + language_server = factory.create_language_server(language) + language_server.start() + if not language_server.is_running(): + raise RuntimeError(f"Failed to start the language server for language {language.value}") + with lock: + language_servers[language] = language_server + except Exception as e: + log.error(f"Error starting language server for language {language.value}: {e}", exc_info=e) + with lock: + exceptions[language] = e + + # start language servers in parallel threads + for language in languages: + thread = threading.Thread(target=start_language_server, args=(language,), name="StartLS:" + language.value) + thread.start() + threads.append(thread) + for thread in threads: + thread.join() + + # If any server failed to start up, raise an exception and stop all started language servers + if exceptions: + for ls in language_servers.values(): + ls.stop() + failure_messages = "\n".join([f"{lang.value}: {e}" for lang, e in exceptions.items()]) + raise Exception(f"Failed to start language servers:\n{failure_messages}") + + return LanguageServerManager(language_servers, factory) + + 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 _create_and_start_language_server(self, language: Language) -> SolidLanguageServer: + if self._language_server_factory is None: + raise ValueError(f"No language server factory available to create language server for {language}") + language_server = self._language_server_factory.create_language_server(language) + self._language_servers[language].start() + self._language_servers[language] = language_server + return language_server + + def restart_language_server(self, language: Language) -> SolidLanguageServer: + """ + Forces recreation and restart of the language server for the given language. + It is assumed that the language server for the given language is no longer running. + + :param language: the language + :return: the newly created language server + """ + if language not in self._language_servers: + raise ValueError(f"No language server for language {language.value} present; cannot restart") + return self._create_and_start_language_server(language) + + def add_language_server(self, language: Language) -> SolidLanguageServer: + """ + Dynamically adds a new language server for the given language. + + :param language: the language + :param factory: the factory to create the language server + :return: the newly created language server + """ + if language in self._language_servers: + raise ValueError(f"Language server for language {language.value} already present") + return self._create_and_start_language_server(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..758b7bf7 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -8,14 +8,14 @@ import pathspec 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.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME +from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.text_utils import MatchedConsecutiveLines, search_files from serena.util.file_system import GitignoreParser, match_path +from serena.util.general import load_yaml, save_yaml from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils -from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -59,6 +59,7 @@ class Project(ToStringMixin): self.project_root = project_root self.project_config = project_config self.memories_manager = MemoriesManager(project_root) + self.language_server_manager: LanguageServerManager | None = None self._is_newly_created = is_newly_created # create .gitignore file in the project's Serena data folder if not yet present @@ -101,10 +102,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() @@ -113,6 +110,16 @@ class Project(ToStringMixin): project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) return Project(project_root=str(project_root), project_config=project_config) + def save_config(self) -> None: + """ + Saves the current project configuration to disk. + """ + config_path = os.path.join(self.project_root, self.project_config.rel_path_to_project_yml()) + log.info("Saving updated project configuration to %s", config_path) + config_with_comments = load_yaml(config_path, preserve_comments=True) + config_with_comments.update(self.project_config.to_yaml_dict()) + save_yaml(config_path, config_with_comments, preserve_comments=True) + def path_to_serena_data_folder(self) -> str: return os.path.join(self.project_root, SERENA_MANAGED_DIR_NAME) @@ -127,7 +134,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 +187,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,43 +358,66 @@ class Project(ToStringMixin): source_file_path=relative_file_path, ) - def create_language_server( + 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. + Creates the language server manager for the project, starting one language server per configured programming language. - :param project: either a path to the project root or a ProjectConfig instance. - If no project.yml is found, the default project configuration will be used. :param log_level: the log level for the language server :param ls_timeout: the timeout for the language server :param trace_lsp_communication: whether to trace LSP communication :param ls_specific_settings: optional LS specific configuration of the language server, see docstrings in the inits of subclasses of SolidLanguageServer to see what values may be passed. - :return: the language server + :return: the language server manager, which is also stored in the project instance """ - 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) + # if there is an existing instance, stop its language servers first + if self.language_server_manager is not None: + log.info("Stopping existing language server manager ...") + self.language_server_manager.stop_all() + self.language_server_manager = None - 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}") + factory = LanguageServerFactory( + project_root=self.project_root, + encoding=self.project_config.encoding, + ignored_patterns=self._ignored_patterns, + ls_timeout=ls_timeout, + ls_specific_settings=ls_specific_settings, + log_level=log_level, + trace_lsp_communication=trace_lsp_communication, ) + self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory) + return self.language_server_manager + + def add_language(self, language: Language) -> None: + """ + Adds a new programming language to the project configuration, starting the corresponding + language server instance if the LS manager is active. + The project configuration is saved to disk after adding the language. + + :param language: the programming language to add + """ + if language in self.project_config.languages: + log.info(f"Language {language.value} is already present in the project configuration.") + return + + # start the language server (if the LS manager is active) + if self.language_server_manager is None: + log.info("Language server manager is not active; skipping language server startup for the new language.") + else: + log.info("Adding and starting the language server for new language %s ...", language.value) + self.language_server_manager.add_language_server(language) + + # update the project configuration + self.project_config.languages.append(language) + self.save_config() + + def shutdown(self) -> None: + if self.language_server_manager is not None: + self.language_server_manager.stop_all(save_cache=True) + self.language_server_manager = None diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 0b2b59d9..f003b361 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -1,9 +1,19 @@ -# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) -# * For C, use cpp -# * For JavaScript, use typescript +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp csharp_omnisharp +# dart elixir elm erlang fortran go +# haskell java julia kotlin lua markdown +# nix perl php python python_jedi r +# rego ruby ruby_solargraph rust scala swift +# terraform typescript typescript_vts zig +# Note: +# - For C, use cpp +# - For JavaScript, use typescript # Special requirements: -# * csharp: Requires the presence of a .sln file in the project folder. -language: python +# - csharp: Requires the presence of a .sln file in the project folder. +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: ["python"] # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings @@ -12,6 +22,7 @@ encoding: "utf-8" # whether to use the project's gitignore file to ignore files # Added on 2025-04-07 ignore_all_files_in_gitignore: true + # list of additional paths to ignore # same syntax as gitignore, so you can use * and ** # Was previously called `ignored_dirs`, please update your config if you are using that. 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..c4b0f300 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -47,9 +47,8 @@ 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.get_language_server_manager_or_raise() + return LanguageServerSymbolRetriever(language_server_manager, agent=self.agent) @property def project(self) -> Project: @@ -239,23 +238,29 @@ class Tool(Component): try: # check whether the tool requires an active project and language server if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): - if self.agent._active_project is None: + if self.agent.get_active_project() is None: return ( "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: + log.error( + f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." + ) + self.agent.get_language_server_manager_or_raise().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 @@ -265,7 +270,7 @@ class Tool(Component): except Exception as e: if not catch_exceptions: raise - msg = f"Error executing tool: {e}" + msg = f"Error executing tool: {e.__class__.__name__} - {e}" log.error(f"Error executing tool: {e}", exc_info=e) result = msg @@ -273,8 +278,9 @@ class Tool(Component): log.info(f"Result: {result}") try: - if self.agent.language_server is not None: - self.agent.language_server.save_cache() + ls_manager = self.agent.get_language_server_manager() + if ls_manager is not None: + ls_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..93575dbb 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, ) @@ -1687,7 +1688,16 @@ class SolidLanguageServer(ABC): return self def stop(self, shutdown_timeout: float = 2.0) -> None: - self._shutdown(timeout=shutdown_timeout) + """ + Stops the language server process. + This function never raises an exception (any exceptions during shutdown are logged). + + :param shutdown_timeout: time, in seconds, to wait for the server to shutdown gracefully before killing it + """ + try: + self._shutdown(timeout=shutdown_timeout) + except Exception as e: + self.logger.log(f"Exception while shutting down language server: {e}", logging.WARNING) @property def language_server(self) -> Self: 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..7dd0189c 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) @@ -211,12 +215,12 @@ class SolidLanguageServerHandler: # start threads to read stdout and stderr of the process threading.Thread( target=self._read_ls_process_stdout, - name="LSP-stdout-reader", + name=f"LSP-stdout-reader:{self.language.value}", daemon=True, ).start() threading.Thread( target=self._read_ls_process_stderr, - name="LSP-stderr-reader", + name=f"LSP-stderr-reader:{self.language.value}", daemon=True, ).start() @@ -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}")