vibefpmgui.ai_edits

Edits proposed by the AI (the Review panel's Edit mode), as search/replace blocks::

<<<<<<< SEARCH
    do i = 1, n
=======
    do j = 1, n
>>>>>>> REPLACE

A block is found in the file by its text, not by line numbers (which small models get wrong, and which every earlier edit would shift). When the exact text isn't there, it's looked for again ignoring trailing spaces, then indentation (the replacement is then re-indented to match), then with line numbers the model copied from the prompt taken out. An edit that matches nothing, or more than one place, isn't applied: it never lands anywhere but where it says.

Pure text functions (no Qt), used by vibefpmgui.window.ai_chat and shown by vibefpmgui.edit_cards.

  1"""Edits proposed by the AI (the Review panel's *Edit* mode), as
  2search/replace blocks::
  3
  4    <<<<<<< SEARCH
  5        do i = 1, n
  6    =======
  7        do j = 1, n
  8    >>>>>>> REPLACE
  9
 10A block is found in the file by its text, not by line numbers (which small
 11models get wrong, and which every earlier edit would shift). When the exact
 12text isn't there, it's looked for again ignoring trailing spaces, then
 13indentation (the replacement is then re-indented to match), then with line
 14numbers the model copied from the prompt taken out. An edit that matches
 15nothing, or more than one place, isn't applied: it never lands anywhere but
 16where it says.
 17
 18Pure text functions (no Qt), used by `vibefpmgui.window.ai_chat` and shown by
 19`vibefpmgui.edit_cards`.
 20"""
 21
 22from __future__ import annotations
 23
 24import re
 25from dataclasses import dataclass
 26
 27START_RE = re.compile(r"^\s*<{5,9}\s*SEARCH\s*$")
 28DIVIDER_RE = re.compile(r"^\s*={5,9}\s*$")
 29END_RE = re.compile(r"^\s*>{5,9}\s*REPLACE\s*$")
 30FENCE_RE = re.compile(r"^\s*```")
 31NUMBER_RE = re.compile(r"^\s*\d+\s?\| ?")  # "  12| " (how the chat numbers a file's lines)
 32
 33PENDING, APPLIED, REJECTED, STALE, FAILED = "pending", "applied", "rejected", "stale", "failed"
 34
 35
 36@dataclass
 37class Edit:
 38    search: str            # as the model wrote it
 39    replace: str
 40    start: int = -1        # where it applies in the text it was located in (offsets), -1: nowhere
 41    end: int = -1
 42    new_text: str = ""     # what goes there (the replacement, re-indented if needed)
 43    line: int = 0          # the first and last lines it replaces (1-based)
 44    end_line: int = 0
 45    how: str = ""          # "", "spacing", "indentation", "line numbers": what had to be ignored
 46    status: str = PENDING
 47    problem: str = ""      # why it can't be applied
 48    diff: list[tuple[str, str]] | None = None  # the change as shown when it was made or turned down
 49
 50    @property
 51    def located(self) -> bool:
 52        return self.start >= 0
 53
 54    def block(self) -> str:
 55        """The block as the model wrote it."""
 56        return f"<<<<<<< SEARCH\n{self.search}\n=======\n{self.replace}\n>>>>>>> REPLACE"
 57
 58
 59def parse_edits(answer: str) -> list[Edit]:
 60    """The complete search/replace blocks in an answer (also one still being
 61    written: a block counts once its REPLACE line has come)."""
 62    edits: list[Edit] = []
 63    state, search, replace = "", [], []
 64    for line in answer.replace("\r\n", "\n").split("\n"):
 65        if state == "":
 66            if START_RE.match(line):
 67                state, search, replace = "search", [], []
 68        elif state == "search":
 69            if DIVIDER_RE.match(line):
 70                state = "replace"
 71            elif START_RE.match(line):  # (a block left unfinished: start again)
 72                search = []
 73            else:
 74                search.append(line)
 75        elif END_RE.match(line):
 76            edits.append(Edit(_unfence(search), _unfence(replace)))
 77            state = ""
 78        else:
 79            replace.append(line)
 80    return edits
 81
 82
 83def _unfence(lines: list[str]) -> str:
 84    """A side of a block, without a ``` fence the model put around it."""
 85    if lines and FENCE_RE.match(lines[0]):
 86        lines = lines[1:]
 87    if lines and FENCE_RE.match(lines[-1]):
 88        lines = lines[:-1]
 89    return "\n".join(lines)
 90
 91
 92def locate(text: str, edit: Edit, tab_width: int = 4) -> Edit:
 93    """Find where ``edit`` applies in ``text``: sets start/end, new_text,
 94    line/end_line and how; or problem (and status FAILED) when it can't."""
 95    edit.start = edit.end = -1
 96    edit.problem, edit.how = "", ""
 97    if edit.status == FAILED:
 98        edit.status = PENDING
 99    if not edit.search.strip():  # (nothing to find: add at the end)
100        joiner = "" if not text or text.endswith("\n") else "\n"
101        _found(text, edit, len(text), len(text), joiner + edit.replace.strip("\n") + "\n", "")
102        return edit
103    count = text.count(edit.search)
104    if count == 1:
105        start = text.index(edit.search)
106        _found(text, edit, start, start + len(edit.search), edit.replace, "")
107        return edit
108    if count > 1:
109        return _fail(edit, f"matches {count} places: ask for a more specific change")
110    search, replace = _trim_blank_ends(edit.search.split("\n"), edit.replace.split("\n"))
111    lines = text.split("\n")
112    for numbers in (False, True):
113        if numbers:  # (the model copied the prompt's line numbers)
114            if not all(NUMBER_RE.match(s) for s in search if s.strip()):
115                break
116            search = [NUMBER_RE.sub("", s, count=1) for s in search]
117            replace = [NUMBER_RE.sub("", r, count=1) for r in replace]
118        for how, same in (("spacing", lambda a, b: a.rstrip() == b.rstrip()),
119                          ("indentation", lambda a, b: a.strip() == b.strip())):
120            places = _places(lines, search, same, tab_width)
121            if len(places) > 1:
122                return _fail(edit, f"matches {len(places)} places: ask for a more specific change")
123            if places:
124                first = places[0]
125                new = replace
126                if how == "indentation":
127                    new = _reindent(replace, search, lines[first:first + len(search)], tab_width)
128                start = sum(len(line) + 1 for line in lines[:first])
129                end = start + len("\n".join(lines[first:first + len(search)]))
130                _found(text, edit, start, end, "\n".join(new), "line numbers" if numbers else how)
131                return edit
132    return _fail(edit, "not found: the model's text doesn't match the file")
133
134
135def _places(lines: list[str], search: list[str], same, tab_width: int) -> list[int]:
136    n = len(search)
137    if n == 0:
138        return []
139    wanted = [s.expandtabs(tab_width) for s in search]
140    return [i for i in range(len(lines) - n + 1)
141            if all(same(lines[i + k].expandtabs(tab_width), wanted[k]) for k in range(n))]
142
143
144def _trim_blank_ends(search: list[str], replace: list[str]) -> tuple[list[str], list[str]]:
145    """Blank lines around SEARCH (models add them) don't have to match; the
146    same number goes from around REPLACE, when it has them too."""
147    while search and not search[0].strip():
148        search = search[1:]
149        if replace and not replace[0].strip():
150            replace = replace[1:]
151    while search and not search[-1].strip():
152        search = search[:-1]
153        if replace and not replace[-1].strip():
154            replace = replace[:-1]
155    return search, replace
156
157
158def _indent(line: str, tab_width: int) -> int:
159    expanded = line.expandtabs(tab_width)
160    return len(expanded) - len(expanded.lstrip(" "))
161
162
163def _reindent(replace: list[str], search: list[str], found: list[str], tab_width: int) -> list[str]:
164    """Shift the replacement by what the model's indentation was off by."""
165    pair = next(((s, f) for s, f in zip(search, found, strict=False) if s.strip()), None)
166    if pair is None:
167        return replace
168    shift = _indent(pair[1], tab_width) - _indent(pair[0], tab_width)
169    if shift == 0:
170        return replace
171    shifted = []
172    for line in replace:
173        if not line.strip():
174            shifted.append(line)
175        elif shift > 0:
176            shifted.append(" " * shift + line)
177        else:
178            line = line.expandtabs(tab_width)
179            shifted.append(line[min(-shift, _indent(line, tab_width)):])
180    return shifted
181
182
183def _found(text: str, edit: Edit, start: int, end: int, new_text: str, how: str):
184    # Removing whole lines: take the line break too, not to leave a blank line.
185    if not new_text and (start == 0 or text[start - 1] == "\n"):
186        if end < len(text) and text[end] == "\n":
187            end += 1
188        elif start > 0 and end == len(text):
189            start -= 1
190    edit.start, edit.end, edit.new_text, edit.how = start, end, new_text, how
191    edit.line = text.count("\n", 0, start) + 1
192    edit.end_line = edit.line + text.count("\n", start, max(start, end - 1))
193
194
195def _fail(edit: Edit, problem: str) -> Edit:
196    edit.problem = problem
197    edit.status = FAILED
198    return edit
199
200
201def apply(text: str, edit: Edit) -> str:
202    """``text`` with a located edit made."""
203    if not edit.located:
204        raise ValueError("the edit wasn't located")
205    return text[:edit.start] + edit.new_text + text[edit.end:]
206
207
208def apply_all(text: str, edits: list[Edit]) -> tuple[str, list[Edit]]:
209    """``text`` with every located edit made (from the end of the text, so
210    the offsets stay right); edits overlapping one already made are left
211    out. Returns the new text and the edits made."""
212    made: list[Edit] = []
213    for edit in sorted((e for e in edits if e.located), key=lambda e: e.start, reverse=True):
214        if made and edit.end > made[-1].start:
215            continue  # (overlaps the next one down)
216        text = apply(text, edit)
217        made.append(edit)
218    return text, made[::-1]
219
220
221def report(edits: list[Edit]) -> str:
222    """What became of an answer's edits, said in the next question ("edit 1,
223    at line 30, was applied; edit 2 was rejected"), so the model knows. (In
224    the user's words, not in the model's answer: small models copied notes
225    put there, or a placeholder for the edit, instead of writing an edit.)"""
226    words = {APPLIED: "was applied", PENDING: "is not applied yet", REJECTED: "was rejected by the user",
227             FAILED: "wasn't found in the file", STALE: "no longer matches the file"}
228    parts = [f"edit {i}{f', at line {e.line},' if e.line else ''} {words.get(e.status, 'was not used')}"
229             for i, e in enumerate(edits, 1)]
230    return f"(About your previous answer's edits: {'; '.join(parts)}.)" if parts else ""
START_RE = re.compile('^\\s*<{5,9}\\s*SEARCH\\s*$')
DIVIDER_RE = re.compile('^\\s*={5,9}\\s*$')
END_RE = re.compile('^\\s*>{5,9}\\s*REPLACE\\s*$')
FENCE_RE = re.compile('^\\s*```')
NUMBER_RE = re.compile('^\\s*\\d+\\s?\\| ?')
@dataclass
class Edit:
37@dataclass
38class Edit:
39    search: str            # as the model wrote it
40    replace: str
41    start: int = -1        # where it applies in the text it was located in (offsets), -1: nowhere
42    end: int = -1
43    new_text: str = ""     # what goes there (the replacement, re-indented if needed)
44    line: int = 0          # the first and last lines it replaces (1-based)
45    end_line: int = 0
46    how: str = ""          # "", "spacing", "indentation", "line numbers": what had to be ignored
47    status: str = PENDING
48    problem: str = ""      # why it can't be applied
49    diff: list[tuple[str, str]] | None = None  # the change as shown when it was made or turned down
50
51    @property
52    def located(self) -> bool:
53        return self.start >= 0
54
55    def block(self) -> str:
56        """The block as the model wrote it."""
57        return f"<<<<<<< SEARCH\n{self.search}\n=======\n{self.replace}\n>>>>>>> REPLACE"
Edit( search: str, replace: str, start: int = -1, end: int = -1, new_text: str = '', line: int = 0, end_line: int = 0, how: str = '', status: str = 'pending', problem: str = '', diff: list[tuple[str, str]] | None = None)
search: str
replace: str
start: int = -1
end: int = -1
new_text: str = ''
line: int = 0
end_line: int = 0
how: str = ''
status: str = 'pending'
problem: str = ''
diff: list[tuple[str, str]] | None = None
located: bool
51    @property
52    def located(self) -> bool:
53        return self.start >= 0
def block(self) -> str:
55    def block(self) -> str:
56        """The block as the model wrote it."""
57        return f"<<<<<<< SEARCH\n{self.search}\n=======\n{self.replace}\n>>>>>>> REPLACE"

The block as the model wrote it.

def parse_edits(answer: str) -> list[Edit]:
60def parse_edits(answer: str) -> list[Edit]:
61    """The complete search/replace blocks in an answer (also one still being
62    written: a block counts once its REPLACE line has come)."""
63    edits: list[Edit] = []
64    state, search, replace = "", [], []
65    for line in answer.replace("\r\n", "\n").split("\n"):
66        if state == "":
67            if START_RE.match(line):
68                state, search, replace = "search", [], []
69        elif state == "search":
70            if DIVIDER_RE.match(line):
71                state = "replace"
72            elif START_RE.match(line):  # (a block left unfinished: start again)
73                search = []
74            else:
75                search.append(line)
76        elif END_RE.match(line):
77            edits.append(Edit(_unfence(search), _unfence(replace)))
78            state = ""
79        else:
80            replace.append(line)
81    return edits

The complete search/replace blocks in an answer (also one still being written: a block counts once its REPLACE line has come).

def locate( text: str, edit: Edit, tab_width: int = 4) -> Edit:
 93def locate(text: str, edit: Edit, tab_width: int = 4) -> Edit:
 94    """Find where ``edit`` applies in ``text``: sets start/end, new_text,
 95    line/end_line and how; or problem (and status FAILED) when it can't."""
 96    edit.start = edit.end = -1
 97    edit.problem, edit.how = "", ""
 98    if edit.status == FAILED:
 99        edit.status = PENDING
100    if not edit.search.strip():  # (nothing to find: add at the end)
101        joiner = "" if not text or text.endswith("\n") else "\n"
102        _found(text, edit, len(text), len(text), joiner + edit.replace.strip("\n") + "\n", "")
103        return edit
104    count = text.count(edit.search)
105    if count == 1:
106        start = text.index(edit.search)
107        _found(text, edit, start, start + len(edit.search), edit.replace, "")
108        return edit
109    if count > 1:
110        return _fail(edit, f"matches {count} places: ask for a more specific change")
111    search, replace = _trim_blank_ends(edit.search.split("\n"), edit.replace.split("\n"))
112    lines = text.split("\n")
113    for numbers in (False, True):
114        if numbers:  # (the model copied the prompt's line numbers)
115            if not all(NUMBER_RE.match(s) for s in search if s.strip()):
116                break
117            search = [NUMBER_RE.sub("", s, count=1) for s in search]
118            replace = [NUMBER_RE.sub("", r, count=1) for r in replace]
119        for how, same in (("spacing", lambda a, b: a.rstrip() == b.rstrip()),
120                          ("indentation", lambda a, b: a.strip() == b.strip())):
121            places = _places(lines, search, same, tab_width)
122            if len(places) > 1:
123                return _fail(edit, f"matches {len(places)} places: ask for a more specific change")
124            if places:
125                first = places[0]
126                new = replace
127                if how == "indentation":
128                    new = _reindent(replace, search, lines[first:first + len(search)], tab_width)
129                start = sum(len(line) + 1 for line in lines[:first])
130                end = start + len("\n".join(lines[first:first + len(search)]))
131                _found(text, edit, start, end, "\n".join(new), "line numbers" if numbers else how)
132                return edit
133    return _fail(edit, "not found: the model's text doesn't match the file")

Find where edit applies in text: sets start/end, new_text, line/end_line and how; or problem (and status FAILED) when it can't.

def apply(text: str, edit: Edit) -> str:
202def apply(text: str, edit: Edit) -> str:
203    """``text`` with a located edit made."""
204    if not edit.located:
205        raise ValueError("the edit wasn't located")
206    return text[:edit.start] + edit.new_text + text[edit.end:]

text with a located edit made.

def apply_all( text: str, edits: list[Edit]) -> tuple[str, list[Edit]]:
209def apply_all(text: str, edits: list[Edit]) -> tuple[str, list[Edit]]:
210    """``text`` with every located edit made (from the end of the text, so
211    the offsets stay right); edits overlapping one already made are left
212    out. Returns the new text and the edits made."""
213    made: list[Edit] = []
214    for edit in sorted((e for e in edits if e.located), key=lambda e: e.start, reverse=True):
215        if made and edit.end > made[-1].start:
216            continue  # (overlaps the next one down)
217        text = apply(text, edit)
218        made.append(edit)
219    return text, made[::-1]

text with every located edit made (from the end of the text, so the offsets stay right); edits overlapping one already made are left out. Returns the new text and the edits made.

def report(edits: list[Edit]) -> str:
222def report(edits: list[Edit]) -> str:
223    """What became of an answer's edits, said in the next question ("edit 1,
224    at line 30, was applied; edit 2 was rejected"), so the model knows. (In
225    the user's words, not in the model's answer: small models copied notes
226    put there, or a placeholder for the edit, instead of writing an edit.)"""
227    words = {APPLIED: "was applied", PENDING: "is not applied yet", REJECTED: "was rejected by the user",
228             FAILED: "wasn't found in the file", STALE: "no longer matches the file"}
229    parts = [f"edit {i}{f', at line {e.line},' if e.line else ''} {words.get(e.status, 'was not used')}"
230             for i, e in enumerate(edits, 1)]
231    return f"(About your previous answer's edits: {'; '.join(parts)}.)" if parts else ""

What became of an answer's edits, said in the next question ("edit 1, at line 30, was applied; edit 2 was rejected"), so the model knows. (In the user's words, not in the model's answer: small models copied notes put there, or a placeholder for the edit, instead of writing an edit.)