diff --git a/CHANGELOG.md b/CHANGELOG.md index a001291c..b85f815a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 "") to be + - read via `ReadFileTool` + - searched via `SearchForPatternTool` + when using plugin version 2023.3.3+ + # v1.6.0 (2026-07-16) * General: diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index 65add4fb..89bdae76 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -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 diff --git a/src/serena/project.py b/src/serena/project.py index 51b27ce3..9cb183d9 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -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 "" :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, diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 5bf224dc..65ecea7b 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -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, diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py index 53685dc8..1362aa16 100644 --- a/src/serena/util/file_proxy.py +++ b/src/serena/util/file_proxy.py @@ -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., "") + """ + + 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