mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 00:16:57 +00:00
Add LanguageServerManager, adding multi-language support in a single project
This commit is contained in:
1 parent
340338f593
commit
e0ab4db189
16 files changed
+277
-127
No files matched your search
+14
-28
@@ -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()
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
|
||||
+12
-10
@@ -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)
|
||||
|
||||
@@ -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", []),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
+61
-29
@@ -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)
|
||||
+28
-21
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in new issue
Block a user