Merge pull request #142 from oraios/better-symbolic-editing

Better symbolic editing
This commit is contained in:
Michael Panchenko
2025-06-02 01:00:07 +02:00
committed by GitHub
15 changed files with 1607 additions and 215 deletions
+8 -1
View File
@@ -3,10 +3,17 @@ Status of the main branch. Changes prior to the next official version change wil
## Highlights
### This version is a major change and improvement of Serena
* **Overhaul and major improvement of editing tools!**
This represents a very important change in Serena. Symbols can now be addressed by their name_path (including nested ones)
and we introduced a regex-based replaced tools. We tuned the prompts and tested the new editing mechanism.
It is much more reliable, flexible, and at the same time uses fewer tokens.
The line-replacement tools are disabled by default and deprecated, we will likely remove them soon.
* **Better multi-project support and zero-config setup**: We significantly simplified the config setup, you no longer need to manually
create `project.yaml` for each project. Project activation is now always available.
Any project can now be activated by just asking the LLM to do so and passing the path to a repo.
* Dashboard as web app and possibility to shut down Serena during
* Dashboard as web app and possibility to shut down Serena from it (or the old log GUI).
* Initial prompt for project supported (has to be added manually for the moment)
* Massive performance improvement of pattern search tool
+30 -2
View File
@@ -5,8 +5,36 @@ prompt: |
Use symbolic editing tools whenever possible for precise code modifications.
If no editing task has yet been provided, wait for the user to provide one.
Your primary tool for editing code is a regex-based replacement. You use other tools to find the relevant content and
then use your knowledge of the codebase to write the regex.
You have two main approaches for editing code - editing by regex and editing by symbol.
The symbol-based approach is appropriate if you need to adjust an entire symbol, e.g. a method, a class, a function, etc.
But it is not appropriate if you need to adjust just a few lines of code within a symbol, for that you should
use the regex-based approach that is described below.
Let us first discuss the symbol-based approach.
Symbols are identified by their name path and relative file path, see the description of the `find_symbols` tool for more details
on how the `name_path` matches symbols.
You can get information about available symbols by using the `get_symbols_overview` tool for finding top-level symbols in a file
or directory, or by using `find_symbol` if you already know the symbol's name path. You generally try to read as little code as possible
while still solving your task, meaning you only read the bodies when you need to, and after you have found the symbol you want to edit.
For example, if you are working with python code and alread know that you need to read the body of the constructor of the class Foo, you can directly
use `find_symbol` with the name path `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit,
you can use `find_symbol` with the name path `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding
to read the desired methods with `include_body=True`.
Note that you never need to add additional indentation, as all symbol editing tools will automatically add the indentation of the symbol that
you are replacing or inserting above or below. In particular, keep in mind the description of the `replace_symbol_body` tool. If you want to add some new code at the end of the file, you should
use the `insert_after_symbol` tool with the last top-level symbol in the file. If you want to add an import, often a good strategy is to use
`insert_before_symbol` with the first top-level symbol in the file.
You can unterstand relationships between symbols by using the `find_referencing_symbols` tool. If not explicitly requested otherwise by a user,
you make sure that when you edit a symbol, it is either done in a backward-compatible way, or you find and adjust the references as needed.
The `find_referencing_symbols` tool will give you code snippets around the references, as well as symbolic information.
You will generally be able to use the info from the snippets and the regex-based approach to adjust the references as well.
You can assume that all symbol editing tools are reliable, so you don't need to verify the results if the tool returns without error.
Now let us discuss the regex-based approach.
The regex-based approach is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation.
This is the case if you need to adjust just a few lines of code within a method, or a chunk that is much smaller than a whole symbol.
You use other tools to find the relevant content and
then use your knowledge of the codebase to write the regex, if you haven't collected enough information of this content yet.
You are extremely good at regex, so you never need to check whether the replacement produced the correct result.
In particular, you know what to escape and what not to escape, and you know how to use wildcards.
Moreover, the replacement tool will fail if it can't perform the desired replacement, and this is all the feedback you need.
+1
View File
@@ -56,6 +56,7 @@ dev = [
"ruff>=0.0.285",
"toml-sort>=0.24.2",
"types-pyyaml>=6.0.12.20241230",
"syrupy>=4.9.1",
]
agno = [
"agno>=1.2.6",
+16 -10
View File
@@ -3,21 +3,27 @@ This script demonstrates how to use Serena's tools locally, useful
for testing or development. Here the tools will be operation the serena repo itself.
"""
import json
from pprint import pprint
from serena.agent import *
from serena.constants import REPO_ROOT
@dataclass
class InMemorySerenaConfig(SerenaConfigBase):
"""
In-memory implementation of Serena configuration with the GUI disabled.
"""
gui_log_window_enabled: bool = False
web_dashboard: bool = False
if __name__ == "__main__":
# project_path = str(Path("test") / "resources" / "repos" / "python" / "test_repo")
agent = SerenaAgent(project=REPO_ROOT)
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
find_symbol_tool = agent.get_tool(FindSymbolTool)
print("Getting an overview of the util package\n")
pprint(json.loads(overview_tool.apply("src/serena/util")))
print("\n\n")
print("Finding the symbol 'SerenaAgent'\n")
pprint(json.loads(find_symbol_tool.apply("SerenaAgent")))
# apply a tool
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
print("Finding the symbol 'SyncLanguageServer'\n")
pprint(json.loads(find_refs_tool.apply(name_path="SyncLanguageServer", relative_file_path="src/multilspy/language_server.py")))
+21 -32
View File
@@ -23,7 +23,6 @@ from typing import AsyncIterator, Dict, Iterator, List, Optional, Tuple, Union,
import pathspec
from serena.text_utils import LineType, MatchedConsecutiveLines, TextLine, search_files
from . import multilspy_types
from .lsp_protocol_handler import lsp_types as LSPTypes
from .lsp_protocol_handler.lsp_constants import LSPConstants
@@ -47,10 +46,19 @@ from .lsp_protocol_handler import lsp_types
# since it caches (in-memory) file contents, so we can avoid reading from disk.
# Moreover, the way we want to use the language server (for retrieving actual content),
# it makes sense to have more content-related utils directly in it.
from serena.text_utils import LineType, MatchedConsecutiveLines, TextLine, search_files
GenericDocumentSymbol = Union[LSPTypes.DocumentSymbol, LSPTypes.SymbolInformation, multilspy_types.UnifiedSymbolInformation]
@dataclasses.dataclass(kw_only=True)
class ReferenceInSymbol:
"""A symbol retrieved when requesting reference to a symbol, together with the location of the reference"""
symbol: multilspy_types.UnifiedSymbolInformation
line: int
character: int
@dataclasses.dataclass
class LSPFileBuffer:
"""
@@ -433,10 +441,9 @@ class LanguageServer:
file_buffer = self.open_file_buffers[uri]
file_buffer.version += 1
change_index = TextUtils.get_index_from_line_col(file_buffer.contents, line, column)
file_buffer.contents = (
file_buffer.contents[:change_index] + text_to_be_inserted + file_buffer.contents[change_index:]
)
new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted)
file_buffer.contents = new_contents
self.server.notify.did_change_text_document(
{
LSPConstants.TEXT_DOCUMENT: {
@@ -454,7 +461,6 @@ class LanguageServer:
],
}
)
new_l, new_c = TextUtils.get_updated_position_from_line_and_column_and_edit(line, column, text_to_be_inserted)
return multilspy_types.Position(line=new_l, character=new_c)
def delete_text_between_positions(
@@ -481,10 +487,8 @@ class LanguageServer:
file_buffer = self.open_file_buffers[uri]
file_buffer.version += 1
del_start_idx = TextUtils.get_index_from_line_col(file_buffer.contents, start["line"], start["character"])
del_end_idx = TextUtils.get_index_from_line_col(file_buffer.contents, end["line"], end["character"])
deleted_text = file_buffer.contents[del_start_idx:del_end_idx]
file_buffer.contents = file_buffer.contents[:del_start_idx] + file_buffer.contents[del_end_idx:]
new_contents, deleted_text = TextUtils.delete_text_between_positions(file_buffer.contents, start_line=start["line"], start_col=start["character"], end_line=end["line"], end_col=end["character"])
file_buffer.contents = new_contents
self.server.notify.did_change_text_document(
{
LSPConstants.TEXT_DOCUMENT: {
@@ -687,22 +691,7 @@ class LanguageServer:
"""
with self.open_file(relative_file_path) as file_data:
file_contents = file_data.contents
line_contents = file_contents.split("\n")
start_lineno = max(0, line - context_lines_before)
end_lineno = min(len(line_contents) - 1, line + context_lines_after)
# instantiate TextLines with the write LineType
text_lines: list[TextLine] = []
# before the line
for lineno in range(start_lineno, line):
text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.BEFORE_MATCH))
# the line
text_lines.append(TextLine(line_number=line, line_content=line_contents[line], match_type=LineType.MATCH))
# after the line
for lineno in range(line + 1, end_lineno + 1):
text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.AFTER_MATCH))
return MatchedConsecutiveLines(lines=text_lines, source_file_path=relative_file_path)
return MatchedConsecutiveLines.from_file_contents(file_contents, line=line, context_lines_before=context_lines_before, context_lines_after=context_lines_after, source_file_path=relative_file_path)
async def request_completions(
@@ -1238,7 +1227,7 @@ class LanguageServer:
include_self: bool = False,
include_body: bool = False,
include_file_symbols: bool = False,
) -> List[multilspy_types.UnifiedSymbolInformation]:
) -> List[ReferenceInSymbol]:
"""
Finds all symbols that reference the symbol at the given location.
This is similar to request_references but filters to only include symbols
@@ -1255,7 +1244,7 @@ class LanguageServer:
:param include_body: whether to include the body of the symbols in the result.
:param include_file_symbols: whether to include references that are file symbols. This
is often a fallback mechanism for when the reference cannot be resolved to a symbol.
:return: List of symbols that reference the target symbol.
:return: List of objects containing the symbol and the location of the reference.
"""
if not self.server_started:
self.logger.log(
@@ -1350,7 +1339,7 @@ class LanguageServer:
):
incoming_symbol = containing_symbol
if include_self:
result.append(containing_symbol)
result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col))
continue
else:
self.logger.log(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}", logging.DEBUG)
@@ -1372,7 +1361,7 @@ class LanguageServer:
)
continue
result.append(containing_symbol)
result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col))
return result
@@ -1921,7 +1910,7 @@ class SyncLanguageServer:
include_imports: bool = True, include_self: bool = False,
include_body: bool = False,
include_file_symbols: bool = False,
) -> List[multilspy_types.UnifiedSymbolInformation]:
) -> List[ReferenceInSymbol]:
"""
Finds all symbols that reference the symbol at the given location.
This is similar to request_references but filters to only include symbols
@@ -1938,7 +1927,7 @@ class SyncLanguageServer:
:param include_body: whether to include the body of the symbols in the result.
:param include_file_symbols: whether to include references that are file symbols. This
is often a fallback mechanism for when the reference cannot be resolved to a symbol.
:return: List of symbols that reference the target symbol.
:return: List of objects containing the symbol and the location of the reference.
"""
assert self.loop
result = asyncio.run_coroutine_threadsafe(
+25 -1
View File
@@ -57,7 +57,7 @@ class TextUtils:
return idx
@staticmethod
def get_updated_position_from_line_and_column_and_edit(l: int, c: int, text_to_be_inserted: str) -> Tuple[int, int]:
def _get_updated_position_from_line_and_column_and_edit(l: int, c: int, text_to_be_inserted: str) -> Tuple[int, int]:
"""
Utility function to get the position of the cursor after inserting text at a given line and column.
"""
@@ -68,6 +68,30 @@ class TextUtils:
else:
c += len(text_to_be_inserted)
return (l, c)
@staticmethod
def delete_text_between_positions(text: str, start_line: int, start_col: int, end_line: int, end_col: int) -> Tuple[str, str]:
"""
Deletes the text between the given start and end positions.
Returns the modified text and the deleted text.
"""
del_start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col)
del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col)
deleted_text = text[del_start_idx:del_end_idx]
new_text = text[:del_start_idx] + text[del_end_idx:]
return new_text, deleted_text
@staticmethod
def insert_text_at_position(text: str, line: int, col: int, text_to_be_inserted: str) -> Tuple[str, int, int]:
"""
Inserts the given text at the given line and column.
Returns the modified text and the new line and column.
"""
change_index = TextUtils.get_index_from_line_col(text, line, col)
new_text = text[:change_index] + text_to_be_inserted + text[change_index:]
new_l, new_c = TextUtils._get_updated_position_from_line_and_column_and_edit(line, col, text_to_be_inserted)
return new_text, new_l, new_c
class PathUtils:
+67 -106
View File
@@ -39,7 +39,7 @@ from serena.config import SerenaAgentContext, SerenaAgentMode
from serena.constants import PROJECT_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME
from serena.dashboard import MemoryLogHandler, SerenaDashboardAPI
from serena.prompt_factory import PromptFactory, SerenaPromptFactory
from serena.symbol import SymbolLocation, SymbolManager
from serena.symbol import SymbolManager
from serena.text_utils import search_files
from serena.util.file_system import scan_directory
from serena.util.general import load_yaml, save_yaml
@@ -1109,7 +1109,7 @@ class GetSymbolsOverviewTool(Tool):
def apply(self, relative_path: str, max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH) -> str:
"""
Gets an overview of the given file or directory.
For each analyzed file, we list the top-level symbols in the file (name, kind, line).
For each analyzed file, we list the top-level symbols in the file (name_path, kind).
Use this tool to get a high-level understanding of the code symbols.
Calling this is often a good idea before more targeted reading, searching or editing operations on the code symbols.
@@ -1118,12 +1118,14 @@ class GetSymbolsOverviewTool(Tool):
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task. If the overview is too long, you should use a smaller directory instead,
(e.g. a subdirectory).
:return: a JSON object mapping relative paths of all contained files to info about top-level symbols in the file (name, kind, line, column).
:return: a JSON object mapping relative paths of all contained files to info about top-level symbols in the file (name_path, kind).
"""
path_to_symbol_infos = self.language_server.request_overview(relative_path)
result = {}
for file_path, symbols in path_to_symbol_infos.items():
result[file_path] = [_tuple_to_info(*symbol_info) for symbol_info in symbols]
# TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables
# The language server methods would need to be adjusted for this.
result[file_path] = [{"name_path": symbol[0], "kind": int(symbol[1])} for symbol in symbols]
result_json_str = json.dumps(result)
return self._limit_length(result_json_str, max_answer_chars)
@@ -1211,98 +1213,50 @@ class FindReferencingSymbolsTool(Tool):
def apply(
self,
relative_path: str,
line: int,
column: int,
include_body: bool = False,
name_path: str,
relative_file_path: str,
include_kinds: list[int] | None = None,
exclude_kinds: list[int] | None = None,
max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH,
) -> str:
"""
Finds symbols that reference the symbol at the given location.
Finds symbols that reference the symbol at the given `name_path`. The result will contain metadata about the referencing symbols
as well as a short code snippet around the reference (unless `include_body` is True, then the short snippet will be omitted).
Note that among other kinds of references, this function can be used to find (direct) subclasses of a class,
as subclasses are referencing symbols that have the kind class.
:param relative_path: the relative path to the file containing the symbol
:param line: the line number
:param column: the column
:param include_body: whether to include the body of the symbols in the result.
Note that this might lead to a very long output, so you should only use this if you actually need the body
of the referencing symbols for the task at hand. Usually it is a better idea to find
the referencing symbols without the body and then use the find_symbol tool to get the body of
specific symbols if needed.
:param include_kinds: an optional list of integers representing the LSP symbol kinds to include.
If provided, only symbols of the given kinds will be included in the result.
Valid kinds:
1=file, 2=module, 3=namespace, 4=package, 5=class, 6=method, 7=property, 8=field, 9=constructor, 10=enum,
11=interface, 12=function, 13=variable, 14=constant, 15=string, 16=number, 17=boolean, 18=array, 19=object,
20=key, 21=null, 22=enum member, 23=struct, 24=event, 25=operator, 26=type parameter
:param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result.
Takes precedence over include_kinds.
:param max_answer_chars: if the output is longer than this number of characters,
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task. Instead, if the output is too long, you should
make a stricter query.
:param name_path: for finding the symbol to find references for, same logic as in the `find_symbol` tool.
:param relative_file_path: the relative path to the file containing the symbol for which to find references.
:param include_kinds: same as in the `find_symbol` tool.
:param exclude_kinds: same as in the `find_symbol` tool.
:param max_answer_chars: same as in the `find_symbol` tool.
:return: a list of JSON objects with the symbols referencing the requested symbol
"""
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_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
symbols = self.symbol_manager.find_referencing_symbols(
SymbolLocation(relative_path, line, column),
references_in_symbols = self.symbol_manager.find_referencing_symbols(
name_path,
relative_file_path=relative_file_path,
include_body=include_body,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
)
symbol_dicts = [s.to_dict(kind=True, location=True, depth=0, include_body=include_body) for s in symbols]
result = json.dumps(symbol_dicts)
reference_dicts = []
for ref in references_in_symbols:
ref_dict = ref.symbol.to_dict(kind=True, location=True, depth=0, include_body=include_body)
if not include_body:
ref_relative_path = ref.symbol.location.relative_path
assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug."
content_around_ref = self.language_server.retrieve_content_around_line(
relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1
)
ref_dict["content_around_reference"] = content_around_ref.to_display_string()
reference_dicts.append(ref_dict)
result = json.dumps(reference_dicts)
return self._limit_length(result, max_answer_chars)
class FindReferencingCodeSnippetsTool(Tool):
"""
Finds code snippets in which the symbol at the given location is referenced.
"""
def apply(
self,
relative_path: str,
line: int,
column: int,
context_lines_before: int = 0,
context_lines_after: int = 0,
max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH,
) -> str:
"""
Returns short code snippets where the symbol at the given location is referenced.
Contrary to the `find_referencing_symbols` tool, this tool returns references that are not symbols but instead
code snippets that may or may not be contained in a symbol (for example, file-level calls).
It may make sense to use this tool to get a quick overview of the code that references
the symbol. Usually, just looking at code snippets is not enough to understand the full context,
unless the case you are investigating is very simple,
or you already have read the relevant symbols using the find_referencing_symbols tool and
now want to get an overview of how the referenced symbol (at the given location) is used in them.
The size of the snippets is controlled by the context_lines_before and context_lines_after parameters.
:param relative_path: the relative path to the file containing the symbol
:param line: the line number of the symbol to find references for
:param column: the column of the symbol to find references for
:param context_lines_before: the number of lines to include before the line containing the reference
:param context_lines_after: the number of lines to include after the line containing the reference
:param max_answer_chars: if the output is longer than this number of characters,
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task. Instead, if the output is too long, you should
make a stricter query.
"""
matches = self.language_server.request_references_with_content(
relative_path, line, column, context_lines_before, context_lines_after
)
result = [match.to_display_string() for match in matches]
result_json_str = json.dumps(result)
return self._limit_length(result_json_str, max_answer_chars)
class ReplaceSymbolBodyTool(Tool, ToolMarkerCanEdit):
"""
Replaces the full definition of a symbol.
@@ -1310,24 +1264,34 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerCanEdit):
def apply(
self,
name_path: str,
relative_path: str,
line: int,
column: int,
body: str,
) -> str:
"""
Replaces the body of the symbol at the given location.
Important: Do not try to guess symbol locations but instead use the find_symbol tool to get the correct location.
r"""
Replaces the body of the symbol with the given `name_path`.
Important:
You don't need to provide an adjusted indentation,
as the tool will automatically add the indentation of the original symbol to each line. For example,
for replacing a method in python, you can just write (using the standard python indentation):
body="def my_method_replacement(self, ...):\n first_line\n second_line...". So each line after the first line only has
an indentation of 4 (the indentation relative to the first characted),
since the additional indentation will be added by the tool. Same for more deeply nested
cases. You always only need to write the relative indentation to the first character of the first line, and that
in turn should not have any indentation.
ALWAYS REMEMBER TO USE THE CORRECT INDENTATION IN THE BODY!
:param name_path: for finding the symbol to replace, same logic as in the `find_symbol` tool.
:param relative_path: the relative path to the file containing the symbol
:param line: the line number
:param column: the column
:param body: the new symbol body. Important: Provide the correct level of indentation
(as the original body). Note that the first line must not be indented (i.e. no leading spaces).
:param body: the new symbol body.
"""
self.symbol_manager.replace_body(
SymbolLocation(relative_path, line, column),
name_path,
relative_file_path=relative_path,
body=body,
use_same_indentation=True,
)
return SUCCESS_RESULT
@@ -1339,24 +1303,24 @@ class InsertAfterSymbolTool(Tool, ToolMarkerCanEdit):
def apply(
self,
name_path: str,
relative_path: str,
line: int,
column: int,
body: str,
) -> str:
"""
Inserts the given body/content after the end of the definition of the given symbol (via the symbol's location).
A typical use case is to insert a new class, function, method, field or variable assignment.
:param name_path: for finding the symbol to insert after, same logic as in the `find_symbol` tool.
:param relative_path: the relative path to the file containing the symbol
:param line: the line number
:param column: the column
:param body: the body/content to be inserted
:param body: the body/content to be inserted. Important: the insterted code will automatically have the
same indentation as the symbol's body, so you do not need to provide any additional indentation.
"""
location = SymbolLocation(relative_path, line, column)
self.symbol_manager.insert_after(
location,
self.symbol_manager.insert_after_symbol(
name_path,
relative_file_path=relative_path,
body=body,
use_same_indentation=True,
)
return SUCCESS_RESULT
@@ -1368,9 +1332,8 @@ class InsertBeforeSymbolTool(Tool, ToolMarkerCanEdit):
def apply(
self,
name_path: str,
relative_path: str,
line: int,
column: int,
body: str,
) -> str:
"""
@@ -1378,14 +1341,16 @@ class InsertBeforeSymbolTool(Tool, ToolMarkerCanEdit):
A typical use case is to insert a new class, function, method, field or variable assignment.
It also can be used to insert a new import statement before the first symbol in the file.
:param name_path: for finding the symbol to insert before, same logic as in the `find_symbol` tool.
:param relative_path: the relative path to the file containing the symbol
:param line: the line number
:param column: the column
:param body: the body/content to be inserted
:param body: the body/content to be inserted. Important: the insterted code will automatically have the
same indentation as the symbol's body, so you do not need to provide any additional indentation.
"""
self.symbol_manager.insert_before(
SymbolLocation(relative_path, line, column),
self.symbol_manager.insert_before_symbol(
name_path,
relative_file_path=relative_path,
body=body,
use_same_indentation=True,
)
return SUCCESS_RESULT
@@ -2006,7 +1971,3 @@ class ToolRegistry:
for tool_name in sorted(tool_dict.keys()):
tool_class = tool_dict[tool_name]
print(f" * `{tool_name}`: {tool_class.get_tool_description().strip()}")
def _tuple_to_info(name: str, symbol_type: SymbolKind, line: int, column: int) -> dict[str, int | str]:
return {"name": name, "symbol_kind": symbol_type, "line": line, "column": column}
+413 -41
View File
@@ -1,14 +1,16 @@
import json
import logging
import os
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from copy import copy
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Any, Self
from dataclasses import asdict, dataclass, field
from difflib import SequenceMatcher
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Self, Union
from sensai.util.string import ToStringMixin
from multilspy import SyncLanguageServer
from multilspy.language_server import ReferenceInSymbol as LSPReferenceInSymbol
from multilspy.multilspy_types import Position, SymbolKind, UnifiedSymbolInformation
if TYPE_CHECKING:
@@ -17,6 +19,151 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
class LineChange(NamedTuple):
"""Represents a change to a specific line or range of lines."""
operation: Literal["insert", "delete", "replace"]
original_start: int
original_end: int
modified_start: int
modified_end: int
original_lines: list[str]
modified_lines: list[str]
@dataclass
class CodeDiff:
"""
Represents the difference between original and modified code.
Provides object-oriented access to diff information including line numbers.
"""
relative_path: str
original_content: str
modified_content: str
_line_changes: list[LineChange] = field(init=False)
def __post_init__(self) -> None:
"""Compute the diff using difflib's SequenceMatcher."""
original_lines = self.original_content.splitlines(keepends=True)
modified_lines = self.modified_content.splitlines(keepends=True)
matcher = SequenceMatcher(None, original_lines, modified_lines)
self._line_changes = []
for tag, orig_start, orig_end, mod_start, mod_end in matcher.get_opcodes():
if tag == "equal":
continue
if tag == "insert":
self._line_changes.append(
LineChange(
operation="insert",
original_start=orig_start,
original_end=orig_start,
modified_start=mod_start,
modified_end=mod_end,
original_lines=[],
modified_lines=modified_lines[mod_start:mod_end],
)
)
elif tag == "delete":
self._line_changes.append(
LineChange(
operation="delete",
original_start=orig_start,
original_end=orig_end,
modified_start=mod_start,
modified_end=mod_start,
original_lines=original_lines[orig_start:orig_end],
modified_lines=[],
)
)
elif tag == "replace":
self._line_changes.append(
LineChange(
operation="replace",
original_start=orig_start,
original_end=orig_end,
modified_start=mod_start,
modified_end=mod_end,
original_lines=original_lines[orig_start:orig_end],
modified_lines=modified_lines[mod_start:mod_end],
)
)
@property
def line_changes(self) -> list[LineChange]:
"""Get all line changes in the diff."""
return self._line_changes
@property
def has_changes(self) -> bool:
"""Check if there are any changes."""
return len(self._line_changes) > 0
@property
def added_lines(self) -> list[tuple[int, str]]:
"""Get all added lines with their line numbers (0-based) in the modified file."""
result = []
for change in self._line_changes:
if change.operation in ("insert", "replace"):
for i, line in enumerate(change.modified_lines):
result.append((change.modified_start + i, line))
return result
@property
def deleted_lines(self) -> list[tuple[int, str]]:
"""Get all deleted lines with their line numbers (0-based) in the original file."""
result = []
for change in self._line_changes:
if change.operation in ("delete", "replace"):
for i, line in enumerate(change.original_lines):
result.append((change.original_start + i, line))
return result
@property
def modified_line_numbers(self) -> list[int]:
"""Get all line numbers (0-based) that were modified in the modified file."""
line_nums: set[int] = set()
for change in self._line_changes:
if change.operation in ("insert", "replace"):
line_nums.update(range(change.modified_start, change.modified_end))
return sorted(line_nums)
@property
def affected_original_line_numbers(self) -> list[int]:
"""Get all line numbers (0-based) that were affected in the original file."""
line_nums: set[int] = set()
for change in self._line_changes:
if change.operation in ("delete", "replace"):
line_nums.update(range(change.original_start, change.original_end))
return sorted(line_nums)
def get_unified_diff(self, context_lines: int = 3) -> str:
"""Get the unified diff as a string."""
import difflib
original_lines = self.original_content.splitlines(keepends=True)
modified_lines = self.modified_content.splitlines(keepends=True)
diff = difflib.unified_diff(
original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines
)
return "".join(diff)
def get_context_diff(self, context_lines: int = 3) -> str:
"""Get the context diff as a string."""
import difflib
original_lines = self.original_content.splitlines(keepends=True)
modified_lines = self.modified_content.splitlines(keepends=True)
diff = difflib.context_diff(
original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines
)
return "".join(diff)
@dataclass
class SymbolLocation:
"""
@@ -322,17 +469,37 @@ class Symbol(ToStringMixin):
return result
@dataclass
class ReferenceInSymbol(ToStringMixin):
"""Same as the class of the same name in the language server, but using Serena's Symbol class.
Be careful to not confuse it with counterpart!
"""
symbol: Symbol
line: int
character: int
def get_relative_path(self) -> str | None:
return self.symbol.location.relative_path
@classmethod
def from_lsp_reference(cls, reference: LSPReferenceInSymbol) -> Self:
return cls(symbol=Symbol(reference.symbol), line=reference.line, character=reference.character)
class SymbolManager:
def __init__(self, lang_server: SyncLanguageServer, agent: "SerenaAgent") -> None:
def __init__(self, lang_server: SyncLanguageServer, agent: Union["SerenaAgent", None] = None) -> None:
"""
:param lang_server: the language server to use for symbol retrieval as well as 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 avare of file modifications performed by the symbol manager.
"""
self.lang_server = lang_server
self.agent = agent
def _to_symbols(self, items: list[UnifiedSymbolInformation]) -> list[Symbol]:
return [Symbol(s) for s in items]
def find_by_name(
self,
name: str,
name_path: str,
include_body: bool = False,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
@@ -348,7 +515,9 @@ class SymbolManager:
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(
Symbol(root).find(name, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching)
Symbol(root).find(
name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
)
)
return symbols
@@ -368,18 +537,54 @@ class SymbolManager:
return None
def find_referencing_symbols(
self,
name_path: str,
relative_file_path: str,
include_body: bool = False,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
) -> list[ReferenceInSymbol]:
"""
Find all symbols that reference the symbol with the given name.
If multiple symbols fit the name (e.g. for variables that are overwritten), will use the first one.
:param name_path: the name path of the symbol to find
:param relative_file_path: the relative path of the file in which the referenced symbol is defined.
:param include_body: whether to include the body of all symbols in the result.
Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long.
:param include_kinds: which kinds of symbols to include in the result.
:param exclude_kinds: which kinds of symbols to exclude from the result.
"""
symbol_candidates = self.find_by_name(name_path, substring_matching=False, within_relative_path=relative_file_path)
if len(symbol_candidates) == 0:
log.warning(f"No symbol with name {name_path} found in file {relative_file_path}")
return []
if len(symbol_candidates) > 1:
log.error(
f"Found {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}."
f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the first one. "
f"Found symbols for {name_path=} in {relative_file_path=}: \n"
f"{json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)}"
)
symbol = symbol_candidates[0]
return self.find_referencing_symbols_by_location(
symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds
)
def find_referencing_symbols_by_location(
self,
symbol_location: SymbolLocation,
include_body: bool = False,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
) -> list[Symbol]:
) -> list[ReferenceInSymbol]:
"""
Find all symbols that reference the symbol at the given location.
:param symbol_location: the location of the symbol for which to find references.
Does not need to include an end_line, as it is unused in the search.
:param include_body: whether to include the body of all symbols in the result.
Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long.
Note: you can filter out the bodies of the children if you set include_children_body=False
in the to_dict method.
:param include_kinds: an optional sequence of ints representing the LSP symbol kind.
@@ -393,22 +598,23 @@ class SymbolManager:
assert symbol_location.relative_path is not None
assert symbol_location.line is not None
assert symbol_location.column is not None
symbol_dicts = self.lang_server.request_referencing_symbols(
references = self.lang_server.request_referencing_symbols(
relative_file_path=symbol_location.relative_path,
line=symbol_location.line,
column=symbol_location.column,
include_imports=False,
include_self=False,
include_body=include_body,
include_file_symbols=True,
)
if include_kinds is not None:
symbol_dicts = [s for s in symbol_dicts if s["kind"] in include_kinds]
references = [s for s in references if s.symbol["kind"] in include_kinds]
if exclude_kinds is not None:
symbol_dicts = [s for s in symbol_dicts if s["kind"] not in exclude_kinds]
references = [s for s in references if s.symbol["kind"] not in exclude_kinds]
return self._to_symbols(symbol_dicts)
return [ReferenceInSymbol.from_lsp_reference(r) for r in references]
@contextmanager
def _edited_file(self, relative_path: str) -> Iterator[None]:
@@ -418,10 +624,14 @@ class SymbolManager:
abs_path = os.path.join(root_path, relative_path)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(file_buffer.contents)
self.agent.mark_file_modified(relative_path)
if self.agent is not None:
self.agent.mark_file_modified(relative_path)
@contextmanager
def _edited_symbol_location(self, location: SymbolLocation) -> Iterator[Symbol]:
"""
Context manager for locating and editing a symbol in a file.
"""
symbol = self.find_by_location(location)
if symbol is None:
raise ValueError("Symbol not found/has no defined location within a file")
@@ -429,27 +639,89 @@ class SymbolManager:
with self._edited_file(location.relative_path):
yield symbol
def replace_body(self, location: SymbolLocation, body: str) -> None:
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)
def replace_body(self, name_path: str, relative_file_path: str, body: str, *, use_same_indentation: bool = True) -> None:
"""
Replace the body of the symbol with the given name_path in the given file.
:param name_path: the name path of the symbol to replace.
:param relative_file_path: the relative path of the file in which the symbol is defined.
:param body: the new body
:param use_same_indentation: whether to use the same indentation as the original body. This means that
the user doesn't have to provide the correct indentation, but can just write the body.
"""
symbol_candidates = self.find_by_name(name_path, within_relative_path=relative_file_path)
if len(symbol_candidates) == 0:
raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}")
if len(symbol_candidates) > 1:
raise ValueError(
f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. "
"Will not replace the body of any of them, but you can use `replace_body_at_location`, the replace lines tool or other editing "
"tools to perform your edits. Their locations are: \n "
+ json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)
)
symbol = symbol_candidates[0]
return self.replace_body_at_location(symbol.location, body, use_same_indentation=use_same_indentation)
def replace_body_at_location(self, location: SymbolLocation, body: str, *, use_same_indentation: bool = True) -> None:
"""
Replace the body of the symbol at the given location with the given body
:param location: the location of the symbol to replace.
:param body: the new body
:param use_same_indentation: whether to use the same indentation as the original body. This means that
the user doesn't have to provide the correct indentation, but can just write the body.
"""
# make sure body always ends with at least one newline
if not body.endswith("\n"):
body += "\n"
with self._edited_symbol_location(location) as symbol:
assert location.relative_path is not None
start_pos = symbol.body_start_position
end_pos = symbol.body_end_position
if start_pos is None or end_pos is None:
raise ValueError(f"Symbol at {location} does not have a defined body range.")
# At this point, start_pos and end_pos are guaranteed to be Position objects
self.lang_server.delete_text_between_positions(location.relative_path, start_pos, end_pos)
self.lang_server.insert_text_at_position(location.relative_path, start_pos["line"], start_pos["character"], body)
start_line, start_col = start_pos["line"], start_pos["character"]
if use_same_indentation:
indent = " " * start_col
body_lines = body.splitlines()
body = body_lines[0] + "\n" + "\n".join(indent + line for line in body_lines[1:])
def insert_after(self, location: SymbolLocation, body: str) -> None:
# make sure body always ends with at least one newline
if not body.endswith("\n"):
body += "\n"
self.lang_server.delete_text_between_positions(location.relative_path, start_pos, end_pos)
self.lang_server.insert_text_at_position(location.relative_path, start_line, start_col, body)
def insert_after_symbol(
self,
name_path: str,
relative_file_path: str,
body: str,
*,
use_same_indentation: bool = True,
at_new_line: bool = True,
) -> None:
"""
Inserts content after the symbol with the given name in the given file.
"""
symbol_candidates = self.find_by_name(name_path, within_relative_path=relative_file_path)
if len(symbol_candidates) == 0:
raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}")
if len(symbol_candidates) > 1:
raise ValueError(
f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. "
f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the last one. "
f"Found symbols at locations: \n" + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)
)
symbol = symbol_candidates[-1]
return self.insert_after_symbol_at_location(
symbol.location, body, at_new_line=at_new_line, use_same_indentation=use_same_indentation
)
def insert_after_symbol_at_location(
self, location: SymbolLocation, body: str, *, at_new_line: bool = True, use_same_indentation: bool = True
) -> None:
"""
Appends content after the given symbol
@@ -459,32 +731,103 @@ class SymbolManager:
# make sure body always ends with at least one newline
if not body.endswith("\n"):
body += "\n"
with self._edited_symbol_location(location) as symbol:
pos = symbol.body_end_position
if pos is None:
raise ValueError(f"Symbol at {location} does not have a defined end position.")
# At this point, pos is guaranteed to be a Position object
assert location.relative_path is not None
self.lang_server.insert_text_at_position(location.relative_path, pos["line"], pos["character"], body)
def insert_before(self, location: SymbolLocation, body: str) -> None:
assert location.relative_path is not None
# Find the symbol to get its end position
symbol = self.find_by_location(location)
if symbol is None:
raise ValueError("Symbol not found/has no defined location within a file")
pos = symbol.body_end_position
if pos is None:
raise ValueError(f"Symbol at {location} does not have a defined end position.")
line, col = pos["line"], pos["character"]
if at_new_line:
line += 1
col = 0
if not body.startswith("\n"):
body = "\n" + body
if use_same_indentation:
symbol_start_pos = symbol.body_start_position
assert symbol_start_pos is not None, f"Symbol at {location=} does not have a defined start position."
symbol_identifier_col = symbol_start_pos["character"]
indent = " " * (symbol_identifier_col)
body = "\n".join(indent + line for line in body.splitlines())
# IMPORTANT: without this, the insertion does the wrong thing. See implementation of insert_text_at_position in TextUtils,
# it is somewhat counterintuitive (never inserts whitespace)
# I am not 100% sure whether col=0 is always the best choice here.
#
# Without col=0, inserting after dataclass_instance in variables.py:
# > dataclass_instance = VariableDataclass(id=1, name="Test")
# > test test
# > dataclass_instancetest test
# > second line
# > .status = "active" # Reassign dataclass field
#
# With col=0:
# > dataclass_instance = VariableDataclass(id=1, name="Test")
# > test test
# > second line
# > dataclass_instance.status = "active" # Reassign dataclass field
col = 0
with self._edited_symbol_location(location):
self.lang_server.insert_text_at_position(location.relative_path, line=line, column=col, text_to_be_inserted=body)
def insert_before_symbol(
self,
name_path: str,
relative_file_path: str,
body: str,
*,
at_new_line: bool = True,
use_same_indentation: bool = True,
) -> None:
"""
Inserts content before the symbol with the given name in the given file.
"""
symbol_candidates = self.find_by_name(name_path, within_relative_path=relative_file_path)
if len(symbol_candidates) == 0:
raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}")
if len(symbol_candidates) > 1:
raise ValueError(
f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. "
f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the first one. "
f"Found symbols at locations: \n" + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)
)
symbol = symbol_candidates[0]
self.insert_before_symbol_at_location(symbol.location, body, at_new_line=at_new_line, use_same_indentation=use_same_indentation)
def insert_before_symbol_at_location(
self, location: SymbolLocation, body: str, *, at_new_line: bool = True, use_same_indentation: bool = True
) -> None:
"""
Inserts content before the given symbol
:param location: the location of the symbol before which to add new lines
:param body: the body of the entity to insert
"""
# make sure body always ends with at least one newline
if not body.endswith("\n"):
body += "\n"
with self._edited_symbol_location(location) as symbol:
original_start_pos = symbol.body_start_position
if original_start_pos is None:
symbol_start_pos = symbol.body_start_position
if symbol_start_pos is None:
raise ValueError(f"Symbol at {location} does not have a defined start position.")
# At this point, original_start_pos is guaranteed to be a Position object, so copying is safe.
pos = copy(original_start_pos)
line = symbol_start_pos["line"]
col = symbol_start_pos["character"]
if use_same_indentation:
indent = " " * (col)
body = "\n".join(indent + line for line in body.splitlines())
# similar problems as in insert_after_symbol_at_location, see comment there
if at_new_line:
col = 0
line -= 1
if not body.endswith("\n"):
body += "\n"
assert location.relative_path is not None
self.lang_server.insert_text_at_position(location.relative_path, pos["line"], pos["character"], body)
self.lang_server.insert_text_at_position(location.relative_path, line=line, column=col, text_to_be_inserted=body)
def insert_at_line(self, relative_path: str, line: int, content: str) -> None:
"""
@@ -503,7 +846,36 @@ class SymbolManager:
:param start_line: the 0-based index of the first line to delete (inclusive)
:param end_line: the 0-based index of the last line to delete (inclusive)
"""
start_col = 0
end_line_for_delete = end_line + 1
end_col = 0
with self._edited_file(relative_path):
start_pos = Position(line=start_line, character=0)
end_pos = Position(line=end_line + 1, character=0)
start_pos = Position(line=start_line, character=start_col)
end_pos = Position(line=end_line_for_delete, character=end_col)
self.lang_server.delete_text_between_positions(relative_path, start_pos, end_pos)
def delete_symbol_at_location(self, location: SymbolLocation) -> None:
"""
Deletes the symbol at the given location.
"""
with self._edited_symbol_location(location) as symbol:
assert location.relative_path is not None
assert symbol.body_start_position is not None
assert symbol.body_end_position is not None
self.lang_server.delete_text_between_positions(location.relative_path, symbol.body_start_position, symbol.body_end_position)
def delete_symbol(self, name_path: str, relative_file_path: str) -> None:
"""
Deletes the symbol with the given name in the given file.
"""
symbol_candidates = self.find_by_name(name_path, within_relative_path=relative_file_path)
if len(symbol_candidates) == 0:
raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}")
if len(symbol_candidates) > 1:
raise ValueError(
f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. "
"Will not delete any of them, but you can use `delete_symbol_at_location` or a corresponding tool to perform your edits. "
"Their locations are: \n " + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)
)
symbol = symbol_candidates[0]
self.delete_symbol_at_location(symbol.location)
+32 -8
View File
@@ -3,7 +3,7 @@ import re
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
from typing import Any, Self
from joblib import Parallel, delayed
from pathspec import PathSpec
@@ -38,11 +38,16 @@ class TextLine:
return " >"
return "..."
def format_line(self) -> str:
"""Format the line for display with line number and content."""
def format_line(self, include_line_numbers: bool = True) -> str:
"""Format the line for display (e.g.,for logging or passing to an LLM).
:param include_line_numbers: Whether to include the line number in the result.
"""
prefix = self.get_display_prefix()
line_num = str(self.line_number).rjust(4)
return f"{prefix}{line_num}: {self.line_content}"
if include_line_numbers:
line_num = str(self.line_number).rjust(4)
prefix = f"{prefix}{line_num}"
return f"{prefix}:{self.line_content}"
@dataclass(kw_only=True)
@@ -52,7 +57,7 @@ class MatchedConsecutiveLines:
"""
lines: list[TextLine]
"""All lines in the context of the match. At least one of them should be of match_type MATCH."""
"""All lines in the context of the match. At least one of them is of `match_type` `MATCH`."""
source_file_path: str | None = None
"""Path to the file where the match was found (Metadata)."""
@@ -84,8 +89,27 @@ class MatchedConsecutiveLines:
def num_matched_lines(self) -> int:
return len(self.matched_lines)
def to_display_string(self) -> str:
return "\n".join([line.format_line() for line in self.lines])
def to_display_string(self, include_line_numbers: bool = True) -> str:
return "\n".join([line.format_line(include_line_numbers) for line in self.lines])
@classmethod
def from_file_contents(
cls, file_contents: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0, source_file_path: str | None = None
) -> Self:
line_contents = file_contents.split("\n")
start_lineno = max(0, line - context_lines_before)
end_lineno = min(len(line_contents) - 1, line + context_lines_after)
text_lines: list[TextLine] = []
# before the line
for lineno in range(start_lineno, line):
text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.BEFORE_MATCH))
# the line
text_lines.append(TextLine(line_number=line, line_content=line_contents[line], match_type=LineType.MATCH))
# after the line
for lineno in range(line + 1, end_lineno + 1):
text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.AFTER_MATCH))
return cls(lines=text_lines, source_file_path=source_file_path)
def search_text(
+6 -2
View File
@@ -22,13 +22,17 @@ def get_repo_path(language: Language) -> Path:
return Path(__file__).parent / "resources" / "repos" / language / "test_repo"
def create_default_ls(language: Language) -> SyncLanguageServer:
def create_ls(language: Language, repo_path: str):
config = MultilspyConfig(code_language=language)
repo_path = str(get_repo_path(language))
logger = MultilspyLogger()
return SyncLanguageServer.create(config, logger, repo_path)
def create_default_ls(language: Language) -> SyncLanguageServer:
repo_path = str(get_repo_path(language))
return create_ls(language, repo_path)
@pytest.fixture(scope="session")
def repo_path(request: LanguageParamRequest) -> Path:
"""Get the repository path for a specific language.
+21 -11
View File
@@ -40,7 +40,7 @@ class TestLanguageServerSymbols:
"""Test request_referencing_symbols for a variable."""
file_path = os.path.join("test_repo", "variables.py")
# Line 75 contains the field status that is later modified
ref_symbols = language_server.request_referencing_symbols(file_path, 74, 4)
ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 74, 4)]
assert len(ref_symbols) > 0
ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_symbols if "location" in ref and "range" in ref["location"]]
@@ -111,7 +111,9 @@ class TestLanguageServerSymbols:
if not create_user_symbol or "selectionRange" not in create_user_symbol:
raise AssertionError("create_user symbol or its selectionRange not found")
sel_start = create_user_symbol["selectionRange"]["start"]
ref_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
ref_symbols = [
ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
]
assert len(ref_symbols) > 0, "No referencing symbols found for create_user (selectionRange)"
# Verify the structure of referencing symbols
@@ -133,7 +135,9 @@ class TestLanguageServerSymbols:
if not user_symbol or "selectionRange" not in user_symbol:
raise AssertionError("User symbol or its selectionRange not found")
sel_start = user_symbol["selectionRange"]["start"]
ref_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
ref_symbols = [
ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
]
services_references = [
symbol
for symbol in ref_symbols
@@ -152,7 +156,9 @@ class TestLanguageServerSymbols:
if not get_user_symbol or "selectionRange" not in get_user_symbol:
raise AssertionError("get_user symbol or its selectionRange not found")
sel_start = get_user_symbol["selectionRange"]["start"]
ref_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
ref_symbols = [
ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
]
method_refs = [
symbol
for symbol in ref_symbols
@@ -169,7 +175,7 @@ class TestLanguageServerSymbols:
file_path = os.path.join("test_repo", "services.py")
# Line 3 is a blank line or comment
try:
ref_symbols = language_server.request_referencing_symbols(file_path, 3, 0)
ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)]
# If we get here, make sure we got an empty result
assert ref_symbols == [] or ref_symbols is None
except Exception:
@@ -448,15 +454,19 @@ class TestLanguageServerSymbols:
# Get the containing symbol of a variable in a file
file_path = os.path.join("test_repo", "services.py")
# import of typing
references_to_typing = language_server.request_referencing_symbols(
file_path, 4, 6, include_imports=False, include_file_symbols=True
)
references_to_typing = [
ref.symbol
for ref in language_server.request_referencing_symbols(file_path, 4, 6, include_imports=False, include_file_symbols=True)
]
assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File}
assert {ref["body"] for ref in references_to_typing} == {""}
# now include bodies
references_to_typing = language_server.request_referencing_symbols(
file_path, 4, 6, include_imports=False, include_file_symbols=True, include_body=True
)
references_to_typing = [
ref.symbol
for ref in language_server.request_referencing_symbols(
file_path, 4, 6, include_imports=False, include_file_symbols=True, include_body=True
)
]
assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File}
assert references_to_typing[0]["body"]
@@ -0,0 +1,713 @@
# serializer version: 1
# name: test_delete_symbol[test_case0]
'''
"""
Test module for variable declarations and usage.
This module tests various types of variable declarations and usages including:
- Module-level variables
- Class-level variables
- Instance variables
- Variable reassignments
"""
from dataclasses import dataclass, field
# Module-level variables
module_var = "Initial module value"
reassignable_module_var = 10
reassignable_module_var = 20 # Reassigned
# Module-level variable with type annotation
typed_module_var: int = 42
# Regular class with class and instance variables
# Dataclass with variables
@dataclass
class VariableDataclass:
"""Dataclass that contains various fields."""
# Field variables with type annotations
id: int
name: str
items: list[str] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
optional_value: float | None = None
# This will be reassigned in various places
status: str = "pending"
# Function that uses the module variables
def use_module_variables():
"""Function that uses module-level variables."""
result = module_var + " used in function"
other_result = reassignable_module_var * 2
return result, other_result
# Create instances and use variables
dataclass_instance = VariableDataclass(id=1, name="Test")
dataclass_instance.status = "active" # Reassign dataclass field
# Use variables at module level
module_result = module_var + " used at module level"
other_module_result = reassignable_module_var + 30
# Create a second dataclass instance with different status
second_dataclass = VariableDataclass(id=2, name="Another Test")
second_dataclass.status = "completed" # Another reassignment of status
'''
# ---
# name: test_delete_symbol[test_case1]
'''
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
}
helperFunction();
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case0-after]
'''
"""
Test module for variable declarations and usage.
This module tests various types of variable declarations and usages including:
- Module-level variables
- Class-level variables
- Instance variables
- Variable reassignments
"""
from dataclasses import dataclass, field
# Module-level variables
module_var = "Initial module value"
reassignable_module_var = 10
reassignable_module_var = 20 # Reassigned
# Module-level variable with type annotation
typed_module_var: int = 42
new_module_var = "Inserted after typed_module_var"
# Regular class with class and instance variables
class VariableContainer:
"""Class that contains various variables."""
# Class-level variables
class_var = "Initial class value"
reassignable_class_var = True
reassignable_class_var = False # Reassigned #noqa: PIE794
# Class-level variable with type annotation
typed_class_var: str = "typed value"
def __init__(self):
# Instance variables
self.instance_var = "Initial instance value"
self.reassignable_instance_var = 100
# Instance variable with type annotation
self.typed_instance_var: list[str] = ["item1", "item2"]
def modify_instance_var(self):
# Reassign instance variable
self.instance_var = "Modified instance value"
self.reassignable_instance_var = 200 # Reassigned
def use_module_var(self):
# Use module-level variables
result = module_var + " used in method"
other_result = reassignable_module_var + 5
return result, other_result
def use_class_var(self):
# Use class-level variables
result = VariableContainer.class_var + " used in method"
other_result = VariableContainer.reassignable_class_var
return result, other_result
# Dataclass with variables
@dataclass
class VariableDataclass:
"""Dataclass that contains various fields."""
# Field variables with type annotations
id: int
name: str
items: list[str] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
optional_value: float | None = None
# This will be reassigned in various places
status: str = "pending"
# Function that uses the module variables
def use_module_variables():
"""Function that uses module-level variables."""
result = module_var + " used in function"
other_result = reassignable_module_var * 2
return result, other_result
# Create instances and use variables
dataclass_instance = VariableDataclass(id=1, name="Test")
dataclass_instance.status = "active" # Reassign dataclass field
# Use variables at module level
module_result = module_var + " used at module level"
other_module_result = reassignable_module_var + 30
# Create a second dataclass instance with different status
second_dataclass = VariableDataclass(id=2, name="Another Test")
second_dataclass.status = "completed" # Another reassignment of status
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case0-before]
'''
"""
Test module for variable declarations and usage.
This module tests various types of variable declarations and usages including:
- Module-level variables
- Class-level variables
- Instance variables
- Variable reassignments
"""
from dataclasses import dataclass, field
# Module-level variables
module_var = "Initial module value"
reassignable_module_var = 10
reassignable_module_var = 20 # Reassigned
new_module_var = "Inserted after typed_module_var"
# Module-level variable with type annotation
typed_module_var: int = 42
# Regular class with class and instance variables
class VariableContainer:
"""Class that contains various variables."""
# Class-level variables
class_var = "Initial class value"
reassignable_class_var = True
reassignable_class_var = False # Reassigned #noqa: PIE794
# Class-level variable with type annotation
typed_class_var: str = "typed value"
def __init__(self):
# Instance variables
self.instance_var = "Initial instance value"
self.reassignable_instance_var = 100
# Instance variable with type annotation
self.typed_instance_var: list[str] = ["item1", "item2"]
def modify_instance_var(self):
# Reassign instance variable
self.instance_var = "Modified instance value"
self.reassignable_instance_var = 200 # Reassigned
def use_module_var(self):
# Use module-level variables
result = module_var + " used in method"
other_result = reassignable_module_var + 5
return result, other_result
def use_class_var(self):
# Use class-level variables
result = VariableContainer.class_var + " used in method"
other_result = VariableContainer.reassignable_class_var
return result, other_result
# Dataclass with variables
@dataclass
class VariableDataclass:
"""Dataclass that contains various fields."""
# Field variables with type annotations
id: int
name: str
items: list[str] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
optional_value: float | None = None
# This will be reassigned in various places
status: str = "pending"
# Function that uses the module variables
def use_module_variables():
"""Function that uses module-level variables."""
result = module_var + " used in function"
other_result = reassignable_module_var * 2
return result, other_result
# Create instances and use variables
dataclass_instance = VariableDataclass(id=1, name="Test")
dataclass_instance.status = "active" # Reassign dataclass field
# Use variables at module level
module_result = module_var + " used at module level"
other_module_result = reassignable_module_var + 30
# Create a second dataclass instance with different status
second_dataclass = VariableDataclass(id=2, name="Another Test")
second_dataclass.status = "completed" # Another reassignment of status
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case1-after]
'''
"""
Test module for variable declarations and usage.
This module tests various types of variable declarations and usages including:
- Module-level variables
- Class-level variables
- Instance variables
- Variable reassignments
"""
from dataclasses import dataclass, field
# Module-level variables
module_var = "Initial module value"
reassignable_module_var = 10
reassignable_module_var = 20 # Reassigned
# Module-level variable with type annotation
typed_module_var: int = 42
# Regular class with class and instance variables
class VariableContainer:
"""Class that contains various variables."""
# Class-level variables
class_var = "Initial class value"
reassignable_class_var = True
reassignable_class_var = False # Reassigned #noqa: PIE794
# Class-level variable with type annotation
typed_class_var: str = "typed value"
def __init__(self):
# Instance variables
self.instance_var = "Initial instance value"
self.reassignable_instance_var = 100
# Instance variable with type annotation
self.typed_instance_var: list[str] = ["item1", "item2"]
def modify_instance_var(self):
# Reassign instance variable
self.instance_var = "Modified instance value"
self.reassignable_instance_var = 200 # Reassigned
def use_module_var(self):
# Use module-level variables
result = module_var + " used in method"
other_result = reassignable_module_var + 5
return result, other_result
def use_class_var(self):
# Use class-level variables
result = VariableContainer.class_var + " used in method"
other_result = VariableContainer.reassignable_class_var
return result, other_result
# Dataclass with variables
@dataclass
class VariableDataclass:
"""Dataclass that contains various fields."""
# Field variables with type annotations
id: int
name: str
items: list[str] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
optional_value: float | None = None
# This will be reassigned in various places
status: str = "pending"
# Function that uses the module variables
def use_module_variables():
"""Function that uses module-level variables."""
result = module_var + " used in function"
other_result = reassignable_module_var * 2
return result, other_result
def new_inserted_function():
print("This is a new function inserted before another.")
# Create instances and use variables
dataclass_instance = VariableDataclass(id=1, name="Test")
dataclass_instance.status = "active" # Reassign dataclass field
# Use variables at module level
module_result = module_var + " used at module level"
other_module_result = reassignable_module_var + 30
# Create a second dataclass instance with different status
second_dataclass = VariableDataclass(id=2, name="Another Test")
second_dataclass.status = "completed" # Another reassignment of status
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case1-before]
'''
"""
Test module for variable declarations and usage.
This module tests various types of variable declarations and usages including:
- Module-level variables
- Class-level variables
- Instance variables
- Variable reassignments
"""
from dataclasses import dataclass, field
# Module-level variables
module_var = "Initial module value"
reassignable_module_var = 10
reassignable_module_var = 20 # Reassigned
# Module-level variable with type annotation
typed_module_var: int = 42
# Regular class with class and instance variables
class VariableContainer:
"""Class that contains various variables."""
# Class-level variables
class_var = "Initial class value"
reassignable_class_var = True
reassignable_class_var = False # Reassigned #noqa: PIE794
# Class-level variable with type annotation
typed_class_var: str = "typed value"
def __init__(self):
# Instance variables
self.instance_var = "Initial instance value"
self.reassignable_instance_var = 100
# Instance variable with type annotation
self.typed_instance_var: list[str] = ["item1", "item2"]
def modify_instance_var(self):
# Reassign instance variable
self.instance_var = "Modified instance value"
self.reassignable_instance_var = 200 # Reassigned
def use_module_var(self):
# Use module-level variables
result = module_var + " used in method"
other_result = reassignable_module_var + 5
return result, other_result
def use_class_var(self):
# Use class-level variables
result = VariableContainer.class_var + " used in method"
other_result = VariableContainer.reassignable_class_var
return result, other_result
# Dataclass with variables
@dataclass
class VariableDataclass:
"""Dataclass that contains various fields."""
# Field variables with type annotations
id: int
name: str
items: list[str] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
optional_value: float | None = None
# This will be reassigned in various places
status: str = "pending"
def new_inserted_function():
print("This is a new function inserted before another.")
# Function that uses the module variables
def use_module_variables():
"""Function that uses module-level variables."""
result = module_var + " used in function"
other_result = reassignable_module_var * 2
return result, other_result
# Create instances and use variables
dataclass_instance = VariableDataclass(id=1, name="Test")
dataclass_instance.status = "active" # Reassign dataclass field
# Use variables at module level
module_result = module_var + " used at module level"
other_module_result = reassignable_module_var + 30
# Create a second dataclass instance with different status
second_dataclass = VariableDataclass(id=2, name="Another Test")
second_dataclass.status = "completed" # Another reassignment of status
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case2-after]
'''
export class DemoClass {
value: number;
constructor(value: number) {
this.value = value;
}
printValue() {
console.log(this.value);
}
}
function newFunctionAfterClass(): void {
console.log("This function is after DemoClass.");
}
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
}
helperFunction();
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case2-before]
'''
function newFunctionAfterClass(): void {
console.log("This function is after DemoClass.");
}
export class DemoClass {
value: number;
constructor(value: number) {
this.value = value;
}
printValue() {
console.log(this.value);
}
}
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
}
helperFunction();
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case3-after]
'''
export class DemoClass {
value: number;
constructor(value: number) {
this.value = value;
}
printValue() {
console.log(this.value);
}
}
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
}
function newInsertedFunction(): void {
console.log("This is a new function inserted before another.");
}
helperFunction();
'''
# ---
# name: test_insert_in_rel_to_symbol[test_case3-before]
'''
export class DemoClass {
value: number;
constructor(value: number) {
this.value = value;
}
printValue() {
console.log(this.value);
}
}
function newInsertedFunction(): void {
console.log("This is a new function inserted before another.");
}
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
}
helperFunction();
'''
# ---
# name: test_replace_body[test_case0]
'''
"""
Test module for variable declarations and usage.
This module tests various types of variable declarations and usages including:
- Module-level variables
- Class-level variables
- Instance variables
- Variable reassignments
"""
from dataclasses import dataclass, field
# Module-level variables
module_var = "Initial module value"
reassignable_module_var = 10
reassignable_module_var = 20 # Reassigned
# Module-level variable with type annotation
typed_module_var: int = 42
# Regular class with class and instance variables
class VariableContainer:
"""Class that contains various variables."""
# Class-level variables
class_var = "Initial class value"
reassignable_class_var = True
reassignable_class_var = False # Reassigned #noqa: PIE794
# Class-level variable with type annotation
typed_class_var: str = "typed value"
def __init__(self):
# Instance variables
self.instance_var = "Initial instance value"
self.reassignable_instance_var = 100
# Instance variable with type annotation
self.typed_instance_var: list[str] = ["item1", "item2"]
def modify_instance_var(self):
# This body has been replaced
self.instance_var = "Replaced!"
self.reassignable_instance_var = 999
# Reassigned
def use_module_var(self):
# Use module-level variables
result = module_var + " used in method"
other_result = reassignable_module_var + 5
return result, other_result
def use_class_var(self):
# Use class-level variables
result = VariableContainer.class_var + " used in method"
other_result = VariableContainer.reassignable_class_var
return result, other_result
# Dataclass with variables
@dataclass
class VariableDataclass:
"""Dataclass that contains various fields."""
# Field variables with type annotations
id: int
name: str
items: list[str] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
optional_value: float | None = None
# This will be reassigned in various places
status: str = "pending"
# Function that uses the module variables
def use_module_variables():
"""Function that uses module-level variables."""
result = module_var + " used in function"
other_result = reassignable_module_var * 2
return result, other_result
# Create instances and use variables
dataclass_instance = VariableDataclass(id=1, name="Test")
dataclass_instance.status = "active" # Reassign dataclass field
# Use variables at module level
module_result = module_var + " used at module level"
other_module_result = reassignable_module_var + 30
# Create a second dataclass instance with different status
second_dataclass = VariableDataclass(id=2, name="Another Test")
second_dataclass.status = "completed" # Another reassignment of status
'''
# ---
# name: test_replace_body[test_case1]
'''
export class DemoClass {
value: number;
constructor(value: number) {
this.value = value;
}
function printValue() {
// This body has been replaced
console.warn("New value: " + this.value);
}
}
export function helperFunction() {
const demo = new DemoClass(42);
demo.printValue();
}
helperFunction();
'''
# ---
+1 -1
View File
@@ -92,7 +92,7 @@ class TestSerenaAgent:
# sel_start = def_symbol["location"]["selectionRange"]["start"]
# Now find references
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
result = find_refs_tool.apply(def_file, loc["line"], loc["column"])
result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_file_path=loc["relative_path"])
refs = json.loads(result)
assert any(
ref["location"]["relative_path"] == ref_file for ref in refs
+239
View File
@@ -0,0 +1,239 @@
import os
import shutil
import tempfile
from abc import abstractmethod
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Literal
import pytest
from multilspy.multilspy_config import Language
from serena.symbol import CodeDiff
from src.serena.symbol import SymbolManager
from test.conftest import create_ls, get_repo_path
class EditingTest:
def __init__(self, language: Language, rel_path: str):
"""
:param language: the language
:param rel_path: the relative path of the edited file
"""
self.rel_path = rel_path
self.language = language
self.original_repo_path = get_repo_path(language)
self.repo_path: Path | None = None
@contextmanager
def _setup(self) -> Iterator[SymbolManager]:
"""Context manager for setup/teardown with a temporary directory, providing the symbol manager."""
temp_dir = Path(tempfile.mkdtemp())
self.repo_path = temp_dir / self.original_repo_path.name
language_server = None # Initialize language_server
try:
shutil.copytree(self.original_repo_path, self.repo_path)
language_server = create_ls(self.language, str(self.repo_path))
language_server.start()
yield SymbolManager(lang_server=language_server)
finally:
if language_server is not None and language_server.is_running():
language_server.stop()
shutil.rmtree(temp_dir)
def _read_file(self, rel_path: str) -> str:
"""Read the content of a file in the test repository."""
assert self.repo_path is not None
file_path = self.repo_path / rel_path
with open(file_path, encoding="utf-8") as f:
return f.read()
def run_test(self, content_after_ground_truth: str) -> None:
with self._setup() as symbol_manager:
content_before = self._read_file(self.rel_path)
self._apply_edit(symbol_manager)
content_after = self._read_file(self.rel_path)
code_diff = CodeDiff(self.rel_path, original_content=content_before, modified_content=content_after)
self._test_diff(code_diff, content_after_ground_truth)
@abstractmethod
def _apply_edit(self, symbol_manager: SymbolManager) -> None:
pass
def _test_diff(self, code_diff: CodeDiff, snapshot) -> None:
assert code_diff.modified_content == snapshot
# Python test file path
PYTHON_TEST_REL_FILE_PATH = os.path.join("test_repo", "variables.py")
# TypeScript test file path
TYPESCRIPT_TEST_FILE = "index.ts"
class DeleteSymbolTest(EditingTest):
def __init__(self, language: Language, rel_path: str, deleted_symbol: str):
super().__init__(language, rel_path)
self.deleted_symbol = deleted_symbol
self.rel_path = rel_path
def _apply_edit(self, symbol_manager: SymbolManager) -> None:
symbol_manager.delete_symbol(self.deleted_symbol, self.rel_path)
@pytest.mark.parametrize(
"test_case",
[
pytest.param(
DeleteSymbolTest(
Language.PYTHON,
PYTHON_TEST_REL_FILE_PATH,
"VariableContainer",
),
marks=pytest.mark.python,
),
pytest.param(
DeleteSymbolTest(
Language.TYPESCRIPT,
TYPESCRIPT_TEST_FILE,
"DemoClass",
),
marks=pytest.mark.typescript,
),
],
)
def test_delete_symbol(test_case, snapshot):
test_case.run_test(content_after_ground_truth=snapshot)
NEW_PYTHON_FUNCTION = """def new_inserted_function():
print("This is a new function inserted before another.")"""
NEW_TYPESCRIPT_FUNCTION = """function newInsertedFunction(): void {
console.log("This is a new function inserted before another.");
}"""
NEW_PYTHON_VARIABLE = 'new_module_var = "Inserted after typed_module_var"'
NEW_TYPESCRIPT_FUNCTION_AFTER = """function newFunctionAfterClass(): void {
console.log("This function is after DemoClass.");
}"""
class InsertInRelToSymbolTest(EditingTest):
def __init__(self, language: Language, rel_path: str, symbol_name: str, new_content: str):
super().__init__(language, rel_path)
self.symbol_name = symbol_name
self.new_content = new_content
self.mode: Literal["before", "after"] | None = None
def set_mode(self, mode: Literal["before", "after"]):
self.mode = mode
def _apply_edit(self, symbol_manager: SymbolManager) -> None:
assert self.mode is not None
if self.mode == "before":
symbol_manager.insert_before_symbol(self.symbol_name, self.rel_path, self.new_content)
elif self.mode == "after":
symbol_manager.insert_after_symbol(self.symbol_name, self.rel_path, self.new_content)
@pytest.mark.parametrize("mode", ["before", "after"])
@pytest.mark.parametrize(
"test_case",
[
pytest.param(
InsertInRelToSymbolTest(
Language.PYTHON,
PYTHON_TEST_REL_FILE_PATH,
"typed_module_var",
NEW_PYTHON_VARIABLE,
),
marks=pytest.mark.python,
),
pytest.param(
InsertInRelToSymbolTest(
Language.PYTHON,
PYTHON_TEST_REL_FILE_PATH,
"use_module_variables",
NEW_PYTHON_FUNCTION,
),
marks=pytest.mark.python,
),
pytest.param(
InsertInRelToSymbolTest(
Language.TYPESCRIPT,
TYPESCRIPT_TEST_FILE,
"DemoClass",
NEW_TYPESCRIPT_FUNCTION_AFTER,
),
marks=pytest.mark.typescript,
),
pytest.param(
InsertInRelToSymbolTest(
Language.TYPESCRIPT,
TYPESCRIPT_TEST_FILE,
"helperFunction",
NEW_TYPESCRIPT_FUNCTION,
),
marks=pytest.mark.typescript,
),
],
)
def test_insert_in_rel_to_symbol(test_case: InsertInRelToSymbolTest, mode: Literal["before", "after"], snapshot):
test_case.set_mode(mode)
test_case.run_test(content_after_ground_truth=snapshot)
PYTHON_REPLACED_BODY = """
def modify_instance_var(self):
# This body has been replaced
self.instance_var = "Replaced!"
self.reassignable_instance_var = 999
"""
TYPESCRIPT_REPLACED_BODY = """
function printValue() {
// This body has been replaced
console.warn("New value: " + this.value);
}
"""
class ReplaceBodyTest(EditingTest):
def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str):
super().__init__(language, rel_path)
self.symbol_name = symbol_name
self.new_body = new_body
def _apply_edit(self, symbol_manager: SymbolManager) -> None:
symbol_manager.replace_body(self.symbol_name, self.rel_path, self.new_body)
@pytest.mark.parametrize(
"test_case",
[
pytest.param(
ReplaceBodyTest(
Language.PYTHON,
PYTHON_TEST_REL_FILE_PATH,
"VariableContainer/modify_instance_var",
PYTHON_REPLACED_BODY,
),
marks=pytest.mark.python,
),
pytest.param(
ReplaceBodyTest(
Language.TYPESCRIPT,
TYPESCRIPT_TEST_FILE,
"DemoClass/printValue",
TYPESCRIPT_REPLACED_BODY,
),
marks=pytest.mark.typescript,
),
],
)
def test_replace_body(test_case: ReplaceBodyTest, snapshot):
test_case.run_test(content_after_ground_truth=snapshot)
Generated
+14
View File
@@ -1006,6 +1006,7 @@ dev = [
{ name = "poethepoet" },
{ name = "pytest" },
{ name = "ruff" },
{ name = "syrupy" },
{ name = "toml-sort" },
{ name = "types-pyyaml" },
]
@@ -1043,6 +1044,7 @@ requires-dist = [
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.285" },
{ name = "sensai-utils", specifier = ">=1.4.0" },
{ name = "sqlalchemy", marker = "extra == 'agno'", specifier = ">=2.0.40" },
{ name = "syrupy", marker = "extra == 'dev'", specifier = ">=4.9.1" },
{ name = "toml-sort", marker = "extra == 'dev'", specifier = ">=0.24.2" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20241230" },
{ name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.12.20241230" },
@@ -1136,6 +1138,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995, upload-time = "2025-03-08T10:55:32.662Z" },
]
[[package]]
name = "syrupy"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/f8/022d8704a3314f3e96dbd6bbd16ebe119ce30e35f41aabfa92345652fceb/syrupy-4.9.1.tar.gz", hash = "sha256:b7d0fcadad80a7d2f6c4c71917918e8ebe2483e8c703dfc8d49cdbb01081f9a4", size = 52492, upload-time = "2025-03-24T01:36:37.225Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/9d/aef9ec5fd5a4ee2f6a96032c4eda5888c5c7cec65cef6b28c4fc37671d88/syrupy-4.9.1-py3-none-any.whl", hash = "sha256:b94cc12ed0e5e75b448255430af642516842a2374a46936dd2650cfb6dd20eda", size = 52214, upload-time = "2025-03-24T01:36:35.278Z" },
]
[[package]]
name = "tokenize-rt"
version = "6.1.0"