JetBrains: Allow external files from dependencies (e.g. "<ext:FileUtil.class|472e0a13>") to be

- read via `ReadFileTool`
  - searched via `SearchForPatternTool`
when using plugin version 2023.3.3+ (yet unreleased)

This is enabled via JetBrainsFileProxy (which reads the file through the IDE)
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-07-16 17:04:58 +02:00
1 parent f1486f7d60
commit f3cf5d3f8d
5 files changed
+91 -27

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* JetBrains:
- Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be
- read via `ReadFileTool`
- searched via `SearchForPatternTool`
when using plugin version 2023.3.3+
# v1.6.0 (2026-07-16)
* General:
+8
View File
@@ -6,6 +6,14 @@ Prefix used for in relative paths of symbols that are from external libraries (i
"""
def is_external_path(relative_path: str):
"""
:param relative_path: a relative path (e.g., from a symbol's `relative_path` field)
:return: whether the path is an external path (i.e., from a library, not the user's codebase)
"""
return relative_path.startswith(JB_EXTERNAL_FILE_PREFIX)
class PluginStatusDTO(TypedDict):
project_root: str
plugin_version: str
+37 -23
View File
@@ -16,12 +16,11 @@ from serena.config.serena_config import (
)
from serena.ls_manager import LanguageServerFactory, LanguageServerManager
from serena.memories.memory_manager import MemoryManager
from serena.util.file_proxy import FileCollection
from serena.util.file_proxy import FileCollection, FileProxy
from serena.util.file_system import GitignoreParser, match_path, scan_directory
from serena.util.text_utils import MatchedConsecutiveLines, search_files
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import FileUtils
if TYPE_CHECKING:
from serena.agent import SerenaAgent
@@ -169,13 +168,13 @@ class Project(ToStringMixin):
def read_file(self, relative_path: str) -> str:
"""
Reads a file relative to the project root.
Reads a project file.
:param relative_path: the path to the file relative to the project root
:param relative_path: the path to the file relative to the project root or an external
path token like "<ext:FileUtil.class|472e0a13>"
:return: the content of the file
"""
abs_path = Path(self.project_root) / relative_path
return FileUtils.read_file(str(abs_path), self.project_config.encoding)
return FileProxy.from_project_relative_path(self, relative_path).get_contents()
@property
def _ignore_spec(self) -> pathspec.PathSpec:
@@ -316,6 +315,9 @@ class Project(ToStringMixin):
:param relative_path: the path to validate, relative to the project root
:param require_not_ignored: if True, the path must not be ignored according to the project's ignore settings
"""
if FileProxy.is_external_path(relative_path):
return
if not self.is_path_in_project(relative_path):
raise ValueError(f"{relative_path=} points outside the project root ({self.project_root})")
@@ -360,6 +362,34 @@ class Project(ToStringMixin):
)
return rel_file_paths
def _create_file_collection(self, relative_path: str, code_files_only: bool) -> FileCollection:
if FileProxy.is_external_path(relative_path):
# single external path: create appropriate proxy
file_collection = FileCollection([FileProxy.from_project_relative_path(self, relative_path)])
else:
# path is a local project path
abs_path = os.path.join(self.project_root, relative_path)
if not os.path.exists(abs_path):
raise FileNotFoundError(f"Relative path {relative_path} does not exist.")
if code_files_only:
relative_file_paths = self.gather_source_files(relative_path=relative_path)
file_collection = FileCollection.from_local_project_paths(relative_file_paths, self)
else:
abs_path = os.path.join(self.project_root, relative_path)
if os.path.isfile(abs_path):
rel_paths_to_search = [relative_path]
else:
_dirs, rel_paths_to_search = scan_directory(
path=abs_path,
recursive=True,
is_ignored_dir=self.is_ignored_path,
is_ignored_file=self.is_ignored_path,
relative_to=self.project_root,
)
file_collection = FileCollection.from_local_project_paths(rel_paths_to_search, self)
return file_collection
def search_project_files_for_pattern(
self,
pattern: str,
@@ -383,23 +413,7 @@ class Project(ToStringMixin):
:param multiline: Whether to compile the regex with the DOTALL flag (``.`` matches newlines).
:return: List of matched consecutive lines with context
"""
if code_files_only:
relative_file_paths = self.gather_source_files(relative_path=relative_path)
file_collection = FileCollection.from_local_project_paths(relative_file_paths, self)
else:
abs_path = os.path.join(self.project_root, relative_path)
if os.path.isfile(abs_path):
rel_paths_to_search = [relative_path]
else:
_dirs, rel_paths_to_search = scan_directory(
path=abs_path,
recursive=True,
is_ignored_dir=self.is_ignored_path,
is_ignored_file=self.is_ignored_path,
relative_to=self.project_root,
)
file_collection = FileCollection.from_local_project_paths(rel_paths_to_search, self)
file_collection = self._create_file_collection(relative_path, code_files_only)
return search_files(
file_collection,
pattern,
-4
View File
@@ -593,10 +593,6 @@ class SearchForPatternTool(Tool):
if relative_path:
self.project.validate_relative_path(relative_path, require_not_ignored=True)
abs_path = os.path.join(self.get_project_root(), relative_path)
if not os.path.exists(abs_path):
raise FileNotFoundError(f"Relative path {relative_path} does not exist.")
matches = self.project.search_project_files_for_pattern(
pattern=substring_pattern,
relative_path=relative_path,
+40
View File
@@ -4,6 +4,8 @@ from abc import ABC, abstractmethod
from collections.abc import Iterator
from typing import TYPE_CHECKING, Self
from serena.jetbrains import jetbrains_types as jb
if TYPE_CHECKING:
from serena.project import Project
@@ -25,6 +27,21 @@ class FileProxy(ABC):
:return: whether the proxy supports glob filtering based on its relative path
"""
@staticmethod
def is_external_path(relative_path: str) -> bool:
"""
:return: whether the given relative path is an encoded external path (not a local project file)
"""
# This is intended to be extended once we also support external paths in other backends
return jb.is_external_path(relative_path)
@classmethod
def from_project_relative_path(cls, project: "Project", relative_path: str) -> "FileProxy":
if cls.is_external_path(relative_path):
if project.language_backend.is_jetbrains():
return JetBrainsFileProxy(relative_path, project)
return LocalProjectFileProxy(relative_path, project)
class LocalProjectFileProxy(FileProxy):
def __init__(self, relative_path: str, project: "Project"):
@@ -43,6 +60,29 @@ class LocalProjectFileProxy(FileProxy):
return True
class JetBrainsFileProxy(FileProxy):
"""
Retrieves the contents of a file from the JetBrains plugin via the plugin client, given its relative path,
which may be an external path (e.g., "<ext:FileUtil.class|472e0a13>")
"""
def __init__(self, relative_path: str, project: "Project"):
self._relative_path = relative_path
self._project = project
def get_contents(self) -> str:
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
client = JetBrainsPluginClient.from_project(self._project)
return client.read_file(self._relative_path)
def get_relative_path(self) -> str:
return self._relative_path
def is_glob_supported(self):
return False
class FileCollection:
def __init__(self, file_proxies: list[FileProxy]):
self._file_proxies = file_proxies