Added SafeDeleteSymbol in LS world

This commit is contained in:
Michael Panchenko committed 2026-03-27 14:07:10 +01:00
1 parent 2aa6603dbb
commit 9735733802
6 files changed
+177 -4

No files matched your search

@@ -9,6 +9,7 @@ excluded_tools:
- find_referencing_symbols
- get_symbols_overview
- restart_language_server
- safe_delete_symbol
included_optional_tools:
- jet_brains_find_symbol
- jet_brains_find_referencing_symbols
+41
View File
@@ -430,3 +430,44 @@ class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit):
code_editor = self.create_code_editor()
status_message = code_editor.rename_symbol(name_path, relative_file_path=relative_path, new_name=new_name)
return status_message
class SafeDeleteSymbol(Tool, ToolMarkerSymbolicEdit):
def apply(
self,
name_path_pattern: str,
relative_path: str,
) -> str:
"""
Deletes the symbol if it is safe to do so (i.e., if there are no references to it)
or returns a list of references to it.
:param name_path_pattern: name path of the symbol to delete (definitions in the `find_symbol` tool apply)
:param relative_path: the relative path to the file containing the symbol to delete
"""
ls_symbol_retriever = self.create_language_server_symbol_retriever()
symbol = ls_symbol_retriever.find_unique(name_path_pattern, substring_matching=False, within_relative_path=relative_path)
symbol_rel_path = symbol.relative_path
assert symbol_rel_path is not None, f"Symbol {name_path_pattern} has no relative path, this is likely a bug."
assert symbol_rel_path == relative_path, f"Symbol {name_path_pattern} is not in the expected relative path {relative_path}."
symbol_name_path = symbol.get_name_path()
symbol_line = symbol.line
symbol_col = symbol.column
assert symbol_line is not None and symbol_col is not None, (
f"Symbol {name_path_pattern} has no identifier position, this is likely a bug."
)
lang_server = ls_symbol_retriever.get_language_server(symbol_rel_path)
references_locations = lang_server.request_references(symbol_rel_path, symbol_line, symbol_col)
file_to_lines: dict[str, list[int]] = defaultdict(list)
if references_locations:
for ref_loc in references_locations:
ref_relative_path = ref_loc.get("relativePath")
if ref_relative_path is None:
continue
file_to_lines[ref_relative_path].append(ref_loc["range"]["start"]["line"])
if file_to_lines:
return f"Cannot delete, the symbol {symbol_name_path} is referenced in: {self._to_json(file_to_lines)}"
code_editor = self.create_ls_code_editor()
code_editor.delete_symbol(symbol_name_path, relative_file_path=symbol_rel_path)
return SUCCESS_RESULT
+10 -3
View File
@@ -21,7 +21,7 @@ from solidlsp.ls_exceptions import SolidLSPException
if TYPE_CHECKING:
from serena.agent import SerenaAgent
from serena.code_editor import CodeEditor
from serena.code_editor import CodeEditor, LanguageServerCodeEditor
from serena.symbol import LanguageServerSymbolRetriever
log = logging.getLogger(__name__)
@@ -58,16 +58,23 @@ class Component(ABC):
return self.agent.get_active_project_or_raise()
def create_code_editor(self) -> "CodeEditor":
from ..code_editor import JetBrainsCodeEditor, LanguageServerCodeEditor
from ..code_editor import JetBrainsCodeEditor
match self.agent.get_language_backend():
case LanguageBackend.LSP:
return LanguageServerCodeEditor(self.create_language_server_symbol_retriever())
return self.create_ls_code_editor()
case LanguageBackend.JETBRAINS:
return JetBrainsCodeEditor(project=self.project)
case _:
raise ValueError
def create_ls_code_editor(self) -> "LanguageServerCodeEditor":
from ..code_editor import LanguageServerCodeEditor
if not self.agent.is_using_language_server():
raise Exception("Cannot create LanguageServerCodeEditor; agent is not in language server mode.")
return LanguageServerCodeEditor(self.create_language_server_symbol_retriever())
class ToolMarker:
"""
@@ -14,3 +14,7 @@ export function helperFunction() {
}
helperFunction();
export function unusedStandaloneFunction(): string {
return "This function is not referenced anywhere";
}
@@ -76,6 +76,10 @@
helperFunction();
export function unusedStandaloneFunction(): string {
return "This function is not referenced anywhere";
}
'''
# ---
# name: test_delete_symbol_vue[test_case0]
@@ -736,6 +740,10 @@
helperFunction();
export function unusedStandaloneFunction(): string {
return "This function is not referenced anywhere";
}
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case2-before]
@@ -761,6 +769,10 @@
helperFunction();
export function unusedStandaloneFunction(): string {
return "This function is not referenced anywhere";
}
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case3-after]
@@ -786,6 +798,10 @@
helperFunction();
export function unusedStandaloneFunction(): string {
return "This function is not referenced anywhere";
}
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case3-before]
@@ -811,6 +827,10 @@
helperFunction();
export function unusedStandaloneFunction(): string {
return "This function is not referenced anywhere";
}
'''
# ---
# name: test_insert_in_rel_to_symbol_vue[test_case0-after]
@@ -1888,6 +1908,10 @@
helperFunction();
export function unusedStandaloneFunction(): string {
return "This function is not referenced anywhere";
}
'''
# ---
# name: test_replace_body_vue[test_case0]
+97 -1
View File
@@ -12,7 +12,14 @@ import pytest
from serena.agent import SerenaAgent
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.project import Project
from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool
from serena.tools import (
SUCCESS_RESULT,
FindReferencingSymbolsTool,
FindSymbolTool,
ReplaceContentTool,
ReplaceSymbolBodyTool,
SafeDeleteSymbol,
)
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from test.conftest import get_repo_path, is_ci, language_tests_enabled
@@ -589,3 +596,92 @@ class TestSerenaAgent:
relative_path="ws_manager.js",
mode="regex",
)
@pytest.mark.parametrize(
"serena_agent,name_path,relative_path",
[
pytest.param(
Language.PYTHON,
"User",
os.path.join("test_repo", "models.py"),
marks=pytest.mark.python,
),
pytest.param(
Language.JAVA,
"Model",
os.path.join("src", "main", "java", "test_repo", "Model.java"),
marks=pytest.mark.java,
),
pytest.param(
Language.KOTLIN,
"Model",
os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"),
marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
),
pytest.param(
Language.TYPESCRIPT,
"helperFunction",
"index.ts",
marks=pytest.mark.typescript,
),
],
indirect=["serena_agent"],
)
def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str):
"""
Tests that SafeDeleteSymbol refuses to delete a symbol that is referenced elsewhere
and returns a message listing the referencing files.
"""
# wrap in modification context as a safety net: if the tool has a bug and deletes anyway,
# the file will be restored, preventing corruption of test resources
with project_file_modification_context(serena_agent, relative_path):
safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol)
result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path)
assert "Cannot delete" in result, f"Expected deletion to be blocked due to existing references, but got: {result}"
assert "referenced in" in result, f"Expected reference information in result, but got: {result}"
@pytest.mark.parametrize(
"serena_agent,name_path,relative_path",
[
pytest.param(
Language.PYTHON,
"Timer",
os.path.join("test_repo", "utils.py"),
marks=pytest.mark.python,
),
pytest.param(
Language.JAVA,
"ModelUser",
os.path.join("src", "main", "java", "test_repo", "ModelUser.java"),
marks=pytest.mark.java,
),
pytest.param(
Language.KOTLIN,
"ModelUser",
os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"),
marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
),
pytest.param(
Language.TYPESCRIPT,
"unusedStandaloneFunction",
"index.ts",
marks=pytest.mark.typescript,
),
],
indirect=["serena_agent"],
)
def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str):
"""
Tests that SafeDeleteSymbol successfully deletes a symbol that has no references
and that the symbol is actually removed from the file.
"""
with project_file_modification_context(serena_agent, relative_path):
safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol)
result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path)
assert result == SUCCESS_RESULT, f"Expected successful deletion, but got: {result}"
# verify the symbol was actually removed from the file
file_content = read_project_file(serena_agent.get_active_project(), relative_path)
assert name_path not in file_content, (
f"Expected symbol {name_path} to be removed from {relative_path}, but it still appears in the file content"
)