Spaces:
Sleeping
Sleeping
| """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 | |