mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 08:18:36 +00:00
Factor out ContentReplacer from ReplaceContentTool
This commit is contained in:
1 parent
15ab4e7747
commit
e38d8de21b
2 files changed
+101
-5
No files matched your search
@@ -7,17 +7,14 @@ File and file system-related tools, specifically for
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional
|
||||
from serena.util.file_system import scan_directory
|
||||
from serena.util.text_utils import search_files
|
||||
from temp.temp_temp import ContentReplacer
|
||||
from serena.util.text_utils import ContentReplacer, search_files
|
||||
|
||||
|
||||
class ReadFileTool(Tool):
|
||||
|
||||
@@ -5,7 +5,7 @@ import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any, Self
|
||||
from typing import Any, Literal, Self
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from joblib import Parallel, delayed
|
||||
@@ -424,3 +424,102 @@ def render_html(html: str) -> str:
|
||||
text = text.replace("\xa0", " ")
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
class ContentReplacer:
|
||||
"""
|
||||
This is an LLM-optimised content replacer, which elegantly circumvents escaping and which
|
||||
provides dual modes for maximum flexibility.
|
||||
"""
|
||||
|
||||
def __init__(self, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool):
|
||||
"""
|
||||
|
||||
:param mode: the mode indicating whether to the needle in replacements corresponds to a regular expression
|
||||
(mode "regex") or to a literal string (mode "literal")
|
||||
:param allow_multiple_occurrences: whether it is allowed that the search expression matches multiple occurrences.
|
||||
If False, an error will be raised if more than one match is found.
|
||||
"""
|
||||
self.mode = mode
|
||||
self.allow_multiple_occurrences = allow_multiple_occurrences
|
||||
|
||||
@staticmethod
|
||||
def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]:
|
||||
"""
|
||||
Creates a replacement function that validates for ambiguity and handles backreferences.
|
||||
|
||||
:param regex_pattern: The regex pattern being used for matching
|
||||
:param repl_template: The replacement template with $!1, $!2, etc. for backreferences
|
||||
:param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE)
|
||||
:return: A function suitable for use with re.sub() or re.subn()
|
||||
"""
|
||||
|
||||
def validate_and_replace(match: re.Match) -> str:
|
||||
matched_text = match.group(0)
|
||||
|
||||
# For multi-line match, check if the same pattern matches again within the already-matched text,
|
||||
# rendering the match ambiguous. Typical pattern in the code:
|
||||
# <start><other-stuff><start><stuff><end>
|
||||
# When matching
|
||||
# <start>.*?<end>
|
||||
# this will match the entire span above, while only the suffix may have been intended.
|
||||
# (See test case for a practical example.)
|
||||
# To detect this, we check if the same pattern matches again within the matched text,
|
||||
if "\n" in matched_text and re.search(regex_pattern, matched_text[1:], flags=regex_flags):
|
||||
raise ValueError(
|
||||
"Match is ambiguous: the search pattern matches multiple overlapping occurrences. "
|
||||
"Please revise the search pattern to be more specific to avoid ambiguity, "
|
||||
"e.g. by matching specific context after the match, or try using the literal mode."
|
||||
)
|
||||
|
||||
# Handle backreferences: replace $!1, $!2, etc. with actual matched groups
|
||||
def expand_backreference(m: re.Match) -> str:
|
||||
group_num = int(m.group(1))
|
||||
group_value = match.group(group_num)
|
||||
return group_value if group_value is not None else m.group(0)
|
||||
|
||||
result = re.sub(r"\$!(\d+)", expand_backreference, repl_template)
|
||||
return result
|
||||
|
||||
return validate_and_replace
|
||||
|
||||
def replace(
|
||||
self,
|
||||
content: str,
|
||||
needle: str,
|
||||
repl: str,
|
||||
) -> str:
|
||||
"""
|
||||
Performs the replacement.
|
||||
|
||||
Raises ValueError if no match is found, or if multiple matches are found while allow_multiple_occurrences is False.
|
||||
|
||||
:param content: the content in which to perform the replacement
|
||||
:param needle: the search expression, which is either a literal string or a regular expression, depending on the mode
|
||||
:param repl: the replacement string, which, in regex mode, may contain backreferences in the form of $!1, $!2, etc. to
|
||||
refer to matched groups in the search expression
|
||||
:return: the updated content after performing the replacement
|
||||
"""
|
||||
if self.mode == "literal":
|
||||
regex = re.escape(needle)
|
||||
elif self.mode == "regex":
|
||||
regex = needle
|
||||
else:
|
||||
raise ValueError(f"Invalid mode: '{self.mode}', expected 'literal' or 'regex'.")
|
||||
|
||||
regex_flags = re.DOTALL | re.MULTILINE
|
||||
|
||||
# create replacement function with validation and backreference handling
|
||||
repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags)
|
||||
|
||||
# perform replacement
|
||||
updated_content, n = re.subn(regex, repl_fn, content, flags=regex_flags)
|
||||
|
||||
if n == 0:
|
||||
raise ValueError("Error: No matches of search expression found.")
|
||||
if not self.allow_multiple_occurrences and n > 1:
|
||||
raise ValueError(
|
||||
f"Expression matches {n} occurrences. "
|
||||
"Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected."
|
||||
)
|
||||
return updated_content
|
||||
Reference in new issue
Block a user