mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-18 00:15:34 +00:00
FindReferencingSymbolsTool: Limit file system sync to the necessary case
(i.e. no full symbol search), adding tests that specifically test the tool path
This commit is contained in:
1 parent
101d7a01d9
commit
ba7cdffc02
3 files changed
+131
-62
No files matched your search
@@ -272,7 +272,9 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
|
|||||||
:param max_answer_chars: max result length; -1 for default
|
:param max_answer_chars: max result length; -1 for default
|
||||||
:return: a list of JSON objects with the symbols referencing the requested symbol
|
:return: a list of JSON objects with the symbols referencing the requested symbol
|
||||||
"""
|
"""
|
||||||
self.project.ls_sync_file_system_changes()
|
# file system sync needed for case where symbol finder does not perform a global search, updating everything
|
||||||
|
if relative_path:
|
||||||
|
self.project.ls_sync_file_system_changes()
|
||||||
|
|
||||||
include_body = False # It is probably never a good idea to include the body of the referencing symbols
|
include_body = False # It is probably never a good idea to include the body of the referencing symbols
|
||||||
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
|
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
|
||||||
|
|||||||
+16
-1
@@ -13,6 +13,7 @@ import pytest
|
|||||||
from _pytest.mark import Mark, MarkDecorator
|
from _pytest.mark import Mark, MarkDecorator
|
||||||
from sensai.util.logging import configure
|
from sensai.util.logging import configure
|
||||||
|
|
||||||
|
from serena.agent import SerenaAgent
|
||||||
from serena.config.serena_config import SerenaConfig, SerenaPaths
|
from serena.config.serena_config import SerenaConfig, SerenaPaths
|
||||||
from serena.constants import SERENA_MANAGED_DIR_NAME
|
from serena.constants import SERENA_MANAGED_DIR_NAME
|
||||||
from serena.project import Project
|
from serena.project import Project
|
||||||
@@ -129,7 +130,7 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer
|
|||||||
|
|
||||||
|
|
||||||
def create_default_serena_config():
|
def create_default_serena_config():
|
||||||
return SerenaConfig(gui_log_window=False, web_dashboard=False)
|
return SerenaConfig().with_headless_mode_overrides()
|
||||||
|
|
||||||
|
|
||||||
def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project:
|
def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project:
|
||||||
@@ -238,6 +239,20 @@ def project_with_ls_context(language: Language, repo_root_override: str | None =
|
|||||||
yield project
|
yield project
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def agent_for_project_context(language: Language, repo_root_override: str | None = None) -> Iterator[SerenaAgent]:
|
||||||
|
project_root = str(get_repo_path(language)) if repo_root_override is None else repo_root_override
|
||||||
|
agent = SerenaAgent(project=project_root, serena_config=create_default_serena_config())
|
||||||
|
|
||||||
|
# wait for agent to be ready
|
||||||
|
agent.execute_task(lambda: None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield agent
|
||||||
|
finally:
|
||||||
|
agent.on_shutdown()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]:
|
def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]:
|
||||||
if not hasattr(request, "param"):
|
if not hasattr(request, "param"):
|
||||||
|
|||||||
@@ -5,84 +5,136 @@ Exercises all three ``FileChangeType`` branches against a real pyright backend:
|
|||||||
Created (new caller file), Changed (append a second caller), Deleted (remove the caller file).
|
Created (new caller file), Changed (append a second caller), Deleted (remove the caller file).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from solidlsp import SolidLanguageServer
|
from serena.agent import SerenaAgent
|
||||||
|
from serena.project import Project
|
||||||
|
from serena.tools import FindReferencingSymbolsTool
|
||||||
from solidlsp.ls_config import Language
|
from solidlsp.ls_config import Language
|
||||||
from test.conftest import get_repo_path, project_with_ls_context
|
from test.conftest import agent_for_project_context, get_repo_path
|
||||||
|
|
||||||
pytestmark = pytest.mark.python
|
pytestmark = pytest.mark.python
|
||||||
|
|
||||||
# A method that already has a call site in the fixture, so its baseline reference set is non-empty.
|
|
||||||
_TARGET_FILE = os.path.join("test_repo", "services.py")
|
|
||||||
_TARGET_SYMBOL = "create_user"
|
|
||||||
|
|
||||||
_CALLER_REL_PATH = os.path.join("test_repo", "external_caller.py")
|
class FileSystemSyncTestCase:
|
||||||
_CALLER_ONE = "external_caller_one"
|
"""
|
||||||
_CALLER_TWO = "external_caller_two"
|
Tests the language server file system synchronisation behaviour, using a reference search
|
||||||
|
as an example of a feature that generally depends on the synchronisation.
|
||||||
|
|
||||||
|
It supports two modes:
|
||||||
|
* using Serena's tools (where an explicit sync is not needed because it builds on find_symbol)
|
||||||
|
* a low-level mode that uses LS methods directly and therefore needs the sync
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A method that already has a call site in the fixture, so its baseline reference set is non-empty.
|
||||||
|
_TARGET_FILE = os.path.join("test_repo", "services.py")
|
||||||
|
_TARGET_SYMBOL = "create_user"
|
||||||
|
|
||||||
|
_CALLER_REL_PATH = os.path.join("test_repo", "external_caller.py")
|
||||||
|
_CALLER_ONE = "external_caller_one"
|
||||||
|
_CALLER_TWO = "external_caller_two"
|
||||||
|
|
||||||
|
def __init__(self, use_serena_tool: bool, tool_use_relative_path: bool = True):
|
||||||
|
"""
|
||||||
|
:param use_serena_tool: whether to use the Serena tool, which does not need an explicit FS sync
|
||||||
|
"""
|
||||||
|
self._use_serena_tool = use_serena_tool
|
||||||
|
self._tool_use_relative_path = tool_use_relative_path
|
||||||
|
|
||||||
|
def _caller_source(self, *function_names: str) -> str:
|
||||||
|
body = "\n\n".join(
|
||||||
|
f"def {name}() -> User:\n return UserService().{self._TARGET_SYMBOL}('x', 'y', 'z@example.com')" for name in function_names
|
||||||
|
)
|
||||||
|
return "from test_repo.models import User\nfrom test_repo.services import UserService\n\n\n" + body + "\n"
|
||||||
|
|
||||||
|
def _referencing_symbol_names(self, agent: SerenaAgent) -> list[str]:
|
||||||
|
"""Names of the symbols that reference ``_TARGET_SYMBOL``, per the warm language server."""
|
||||||
|
if self._use_serena_tool:
|
||||||
|
tool = agent.get_tool(FindReferencingSymbolsTool)
|
||||||
|
with tool.symbol_dict_grouper.disabled_context():
|
||||||
|
relative_path = self._TARGET_FILE if self._tool_use_relative_path else ""
|
||||||
|
response = tool.apply(name_path=self._TARGET_SYMBOL, relative_path=relative_path)
|
||||||
|
ref_symbols = json.loads(response)
|
||||||
|
symbol_names = [ref["name_path"].split("/")[-1] for ref in ref_symbols]
|
||||||
|
return symbol_names
|
||||||
|
else:
|
||||||
|
ls = next(iter(agent.get_active_project_or_raise().language_server_manager.iter_language_servers()))
|
||||||
|
document_symbols = ls.request_document_symbols(self._TARGET_FILE).get_all_symbols_and_roots()
|
||||||
|
target = next((s for s in document_symbols[0] if s.get("name") == self._TARGET_SYMBOL), None)
|
||||||
|
assert target is not None and "selectionRange" in target, f"{self._TARGET_SYMBOL} not found in {self._TARGET_FILE}"
|
||||||
|
start = target["selectionRange"]["start"]
|
||||||
|
references = ls.request_referencing_symbols(self._TARGET_FILE, start["line"], start["character"])
|
||||||
|
return [ref.symbol["name"] for ref in references]
|
||||||
|
|
||||||
|
def run(self, tmp_path):
|
||||||
|
# Work on an isolated copy so we can freely create/edit/delete files under the project root.
|
||||||
|
repo_root = tmp_path / "repo"
|
||||||
|
shutil.copytree(get_repo_path(Language.PYTHON), repo_root)
|
||||||
|
caller_abs = repo_root / self._CALLER_REL_PATH
|
||||||
|
|
||||||
|
with agent_for_project_context(Language.PYTHON, str(repo_root)) as agent:
|
||||||
|
project = agent.get_active_project_or_raise()
|
||||||
|
|
||||||
|
# Warm the reference index, then establish the freshness baseline (first poll never notifies).
|
||||||
|
self._referencing_symbol_names(agent)
|
||||||
|
self._sync_fs(project, 0, "<baseline>")
|
||||||
|
|
||||||
|
# --- Created -------------------------------------------------------------------------------
|
||||||
|
caller_abs.write_text(self._caller_source(self._CALLER_ONE), encoding="utf-8")
|
||||||
|
|
||||||
|
if not self._use_serena_tool:
|
||||||
|
# Adversarial: without the poll, the warm server has never seen the new file -> stale.
|
||||||
|
assert self._CALLER_ONE not in self._referencing_symbol_names(agent), "expected the new caller to be invisible before poll"
|
||||||
|
|
||||||
|
self._sync_fs(project, 1, "Created")
|
||||||
|
assert self._CALLER_ONE in self._referencing_symbol_names(agent), "new caller must be visible after poll"
|
||||||
|
|
||||||
|
# --- Changed -------------------------------------------------------------------------------
|
||||||
|
caller_abs.write_text(self._caller_source(self._CALLER_ONE, self._CALLER_TWO), encoding="utf-8")
|
||||||
|
self._sync_fs(project, 1, "Changed")
|
||||||
|
names_after_change = self._referencing_symbol_names(agent)
|
||||||
|
assert self._CALLER_TWO in names_after_change, "appended caller must be visible after poll"
|
||||||
|
assert self._CALLER_ONE in names_after_change
|
||||||
|
|
||||||
|
# --- Deleted -------------------------------------------------------------------------------
|
||||||
|
caller_abs.unlink()
|
||||||
|
self._sync_fs(project, 1, "Deleted")
|
||||||
|
names_after_delete = self._referencing_symbol_names(agent)
|
||||||
|
assert self._CALLER_ONE not in names_after_delete, "deleted caller must disappear after poll"
|
||||||
|
assert self._CALLER_TWO not in names_after_delete
|
||||||
|
|
||||||
|
def _sync_fs(self, project: Project, num_expected_events: int, event_type: str):
|
||||||
|
# apply sync only if Serena's tool is not being used
|
||||||
|
if not self._use_serena_tool:
|
||||||
|
assert project.ls_sync_file_system_changes() == num_expected_events, f"expected {num_expected_events} {event_type} event(s)"
|
||||||
|
|
||||||
|
|
||||||
def _caller_source(*function_names: str) -> str:
|
def test_find_referencing_symbol_tool_handles_sync_with_relative_path(tmp_path):
|
||||||
body = "\n\n".join(
|
"""
|
||||||
f"def {name}() -> User:\n return UserService().{_TARGET_SYMBOL}('x', 'y', 'z@example.com')" for name in function_names
|
Tests that the FindReferencingSymbolsTool handles file system synchronisation correctly when passed a relative path.
|
||||||
)
|
"""
|
||||||
return "from test_repo.models import User\nfrom test_repo.services import UserService\n\n\n" + body + "\n"
|
FileSystemSyncTestCase(use_serena_tool=True, tool_use_relative_path=True).run(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
def _referencing_symbol_names(ls: SolidLanguageServer) -> list[str]:
|
def test_find_referencing_symbol_tool_handles_sync_without_relative_path(tmp_path):
|
||||||
"""Names of the symbols that reference ``_TARGET_SYMBOL``, per the warm language server."""
|
"""
|
||||||
document_symbols = ls.request_document_symbols(_TARGET_FILE).get_all_symbols_and_roots()
|
Tests that the FindReferencingSymbolsTool handles file system synchronisation correctly when no relative path is provided.
|
||||||
target = next((s for s in document_symbols[0] if s.get("name") == _TARGET_SYMBOL), None)
|
"""
|
||||||
assert target is not None and "selectionRange" in target, f"{_TARGET_SYMBOL} not found in {_TARGET_FILE}"
|
FileSystemSyncTestCase(use_serena_tool=True, tool_use_relative_path=False).run(tmp_path)
|
||||||
start = target["selectionRange"]["start"]
|
|
||||||
references = ls.request_referencing_symbols(_TARGET_FILE, start["line"], start["character"])
|
|
||||||
return [ref.symbol["name"] for ref in references]
|
|
||||||
|
|
||||||
|
|
||||||
def test_external_changes_become_visible_after_sync(tmp_path):
|
def test_ls_low_level_find_references_with_explicit_sync(tmp_path):
|
||||||
# Work on an isolated copy so we can freely create/edit/delete files under the project root.
|
"""
|
||||||
repo_root = tmp_path / "repo"
|
Tests that requesting references directly from the LS works if synchronisation is explicitly requested after external file changes.
|
||||||
shutil.copytree(get_repo_path(Language.PYTHON), repo_root)
|
"""
|
||||||
caller_abs = repo_root / _CALLER_REL_PATH
|
FileSystemSyncTestCase(use_serena_tool=False).run(tmp_path)
|
||||||
|
|
||||||
with project_with_ls_context(Language.PYTHON, str(repo_root)) as project:
|
|
||||||
language_server = next(iter(project.language_server_manager.iter_language_servers()))
|
|
||||||
|
|
||||||
def sync_fs() -> int:
|
|
||||||
return project.language_server_manager.sync_file_system_changes()
|
|
||||||
|
|
||||||
# Warm the reference index, then establish the freshness baseline (first poll never notifies).
|
|
||||||
_referencing_symbol_names(language_server)
|
|
||||||
assert sync_fs() == 0, "first poll must only establish the baseline"
|
|
||||||
|
|
||||||
# --- Created -------------------------------------------------------------------------------
|
|
||||||
caller_abs.write_text(_caller_source(_CALLER_ONE), encoding="utf-8")
|
|
||||||
|
|
||||||
# Adversarial: without the poll, the warm server has never seen the new file -> stale.
|
|
||||||
assert _CALLER_ONE not in _referencing_symbol_names(language_server), "expected the new caller to be invisible before poll"
|
|
||||||
|
|
||||||
assert sync_fs() == 1, "one Created event expected"
|
|
||||||
assert _CALLER_ONE in _referencing_symbol_names(language_server), "new caller must be visible after poll"
|
|
||||||
|
|
||||||
# --- Changed -------------------------------------------------------------------------------
|
|
||||||
caller_abs.write_text(_caller_source(_CALLER_ONE, _CALLER_TWO), encoding="utf-8")
|
|
||||||
assert sync_fs() == 1, "one Changed event expected"
|
|
||||||
names_after_change = _referencing_symbol_names(language_server)
|
|
||||||
assert _CALLER_TWO in names_after_change, "appended caller must be visible after poll"
|
|
||||||
assert _CALLER_ONE in names_after_change
|
|
||||||
|
|
||||||
# --- Deleted -------------------------------------------------------------------------------
|
|
||||||
caller_abs.unlink()
|
|
||||||
assert sync_fs() == 1, "one Deleted event expected"
|
|
||||||
names_after_delete = _referencing_symbol_names(language_server)
|
|
||||||
assert _CALLER_ONE not in names_after_delete, "deleted caller must disappear after poll"
|
|
||||||
assert _CALLER_TWO not in names_after_delete
|
|
||||||
|
|
||||||
|
|
||||||
def test_first_poll_is_baseline_only(tmp_path):
|
def test_sync_first_poll_is_baseline_only(tmp_path):
|
||||||
"""A newly-loaded project must not report its entire pre-existing file set as changed."""
|
"""A newly-loaded project must not report its entire pre-existing file set as changed."""
|
||||||
repo_root = tmp_path / "repo"
|
repo_root = tmp_path / "repo"
|
||||||
shutil.copytree(get_repo_path(Language.PYTHON), repo_root)
|
shutil.copytree(get_repo_path(Language.PYTHON), repo_root)
|
||||||
|
|||||||
Reference in new issue
Block a user