Spaces:
Sleeping
Sleeping
File size: 6,563 Bytes
5ea571a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | """Forgiving search-and-replace matching for the ``edit_file`` tool.
The functions in this module are ported (lightly adapted) from Aider's
``aider/coders/editblock_coder.py``:
https://github.com/Aider-AI/aider β Copyright (c) Paul Gauthier
Licensed under the Apache License, Version 2.0.
We borrow only Aider's *matching* layer β given an ``old`` chunk and a ``new``
chunk, locate ``old`` in the file content even when it differs by leading
whitespace or elides lines with ``...`` β not its prose edit-format parser.
cli-textual is a tool-calling agent, so the model supplies ``old``/``new`` as
structured tool arguments; this module just makes the replacement robust so a
small whitespace drift doesn't fail the edit. See ../../../NOTICE.
Faithful to Aider's *active* escalation path (it deliberately stops before the
edit-distance fallback): perfect match β whitespace-flexible β drop spurious
leading blank line β ``...`` elision handling. No third-party dependencies.
"""
import re
def prep(content):
if content and not content.endswith("\n"):
content += "\n"
lines = content.splitlines(keepends=True)
return content, lines
def perfect_replace(whole_lines, part_lines, replace_lines):
part_tup = tuple(part_lines)
part_len = len(part_lines)
for i in range(len(whole_lines) - part_len + 1):
whole_tup = tuple(whole_lines[i : i + part_len])
if part_tup == whole_tup:
res = whole_lines[:i] + replace_lines + whole_lines[i + part_len :]
return "".join(res)
def match_but_for_leading_whitespace(whole_lines, part_lines):
num = len(whole_lines)
# does the non-whitespace all agree?
if not all(whole_lines[i].lstrip() == part_lines[i].lstrip() for i in range(num)):
return
# are they all offset the same?
add = set(
whole_lines[i][: len(whole_lines[i]) - len(part_lines[i])]
for i in range(num)
if whole_lines[i].strip()
)
if len(add) != 1:
return
return add.pop()
def replace_part_with_missing_leading_whitespace(whole_lines, part_lines, replace_lines):
# Models often mess up leading whitespace, usually uniformly across the
# old and new blocks β omitting all of it, or only some.
#
# Known limitation: this returns the FIRST indentation-tolerant match rather
# than detecting multiple ambiguous matches. It's reached only as a fallback
# in edit_file when exact matching already found zero hits (the exact-match
# uniqueness guard runs first), and it mirrors Aider's upstream behavior.
# Tightening it to surface "multiple forgiving matches" is a future enhancement.
# Outdent everything by the max fixed amount possible.
leading = [len(p) - len(p.lstrip()) for p in part_lines if p.strip()] + [
len(p) - len(p.lstrip()) for p in replace_lines if p.strip()
]
if leading and min(leading):
num_leading = min(leading)
part_lines = [p[num_leading:] if p.strip() else p for p in part_lines]
replace_lines = [p[num_leading:] if p.strip() else p for p in replace_lines]
# can we find an exact match not including the leading whitespace?
num_part_lines = len(part_lines)
for i in range(len(whole_lines) - num_part_lines + 1):
add_leading = match_but_for_leading_whitespace(
whole_lines[i : i + num_part_lines], part_lines
)
if add_leading is None:
continue
replace_lines = [add_leading + rline if rline.strip() else rline for rline in replace_lines]
whole_lines = whole_lines[:i] + replace_lines + whole_lines[i + num_part_lines :]
return "".join(whole_lines)
return None
def perfect_or_whitespace(whole_lines, part_lines, replace_lines):
# Try for a perfect match
res = perfect_replace(whole_lines, part_lines, replace_lines)
if res:
return res
# Try being flexible about leading whitespace
res = replace_part_with_missing_leading_whitespace(whole_lines, part_lines, replace_lines)
if res:
return res
def try_dotdotdots(whole, part, replace):
"""Handle edits whose ``old``/``new`` chunks elide lines with ``...``.
Returns the updated content, or ``None`` if there are no ``...`` lines.
Raises ``ValueError`` if the ``...`` markers don't pair up or match.
"""
dots_re = re.compile(r"(^\s*\.\.\.\n)", re.MULTILINE | re.DOTALL)
part_pieces = re.split(dots_re, part)
replace_pieces = re.split(dots_re, replace)
if len(part_pieces) != len(replace_pieces):
raise ValueError("Unpaired ... in edit block")
if len(part_pieces) == 1:
# no dots in this edit block, just return None
return
# Compare the ... separators (odd indices) in both
all_dots_match = all(part_pieces[i] == replace_pieces[i] for i in range(1, len(part_pieces), 2))
if not all_dots_match:
raise ValueError("Unmatched ... in edit block")
part_pieces = [part_pieces[i] for i in range(0, len(part_pieces), 2)]
replace_pieces = [replace_pieces[i] for i in range(0, len(replace_pieces), 2)]
pairs = zip(part_pieces, replace_pieces)
for part, replace in pairs:
if not part and not replace:
continue
if not part and replace:
if not whole.endswith("\n"):
whole += "\n"
whole += replace
continue
if whole.count(part) == 0:
raise ValueError
if whole.count(part) > 1:
raise ValueError
whole = whole.replace(part, replace, 1)
return whole
def replace_most_similar_chunk(whole, part, replace):
"""Best effort to find ``part`` in ``whole`` and replace it with ``replace``.
Returns the updated content, or ``None`` if no acceptable match was found.
"""
whole, whole_lines = prep(whole)
part, part_lines = prep(part)
replace, replace_lines = prep(replace)
res = perfect_or_whitespace(whole_lines, part_lines, replace_lines)
if res:
return res
# drop a spurious leading blank line that models sometimes add
if len(part_lines) > 2 and not part_lines[0].strip():
skip_blank_line_part_lines = part_lines[1:]
res = perfect_or_whitespace(whole_lines, skip_blank_line_part_lines, replace_lines)
if res:
return res
# handle elision with ...
try:
res = try_dotdotdots(whole, part, replace)
if res:
return res
except ValueError:
pass
return None
|