James Lindsay
feat: add write_file and edit_file tools (#14)
5ea571a unverified
Raw
History Blame Contribute Delete
6.56 kB
"""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