mirror of
https://github.com/tiennm99/serena.git
synced 2026-07-18 20:18:58 +00:00
Merge pull request #142 from oraios/better-symbolic-editing
Better symbolic editing
This commit is contained in:
+8
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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")))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
'''
|
||||
# ---
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user