Coverage for vibefpmgui/ai_edits.py: 93%
153 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Edits proposed by the AI (the Review panel's *Edit* mode), as
2search/replace blocks::
4 <<<<<<< SEARCH
5 do i = 1, n
6 =======
7 do j = 1, n
8 >>>>>>> REPLACE
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.
18Pure text functions (no Qt), used by `vibefpmgui.window.ai_chat` and shown by
19`vibefpmgui.edit_cards`.
20"""
22from __future__ import annotations
24import re
25from dataclasses import dataclass
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)
33PENDING, APPLIED, REJECTED, STALE, FAILED = "pending", "applied", "rejected", "stale", "failed"
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
50 @property
51 def located(self) -> bool:
52 return self.start >= 0
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"
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) 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
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
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)
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): 112 ↛ 132line 112 didn't jump to line 132 because the loop on line 112 didn't complete
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: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
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")
135def _places(lines: list[str], search: list[str], same, tab_width: int) -> list[int]:
136 n = len(search)
137 if n == 0: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
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))]
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(): 149 ↛ 147line 149 didn't jump to line 147 because the condition on line 149 was always true
150 replace = replace[1:]
151 while search and not search[-1].strip():
152 search = search[:-1]
153 if replace and not replace[-1].strip(): 153 ↛ 151line 153 didn't jump to line 151 because the condition on line 153 was always true
154 replace = replace[:-1]
155 return search, replace
158def _indent(line: str, tab_width: int) -> int:
159 expanded = line.expandtabs(tab_width)
160 return len(expanded) - len(expanded.lstrip(" "))
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: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 return replace
168 shift = _indent(pair[1], tab_width) - _indent(pair[0], tab_width)
169 if shift == 0: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 return replace
171 shifted = []
172 for line in replace:
173 if not line.strip(): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
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
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))
195def _fail(edit: Edit, problem: str) -> Edit:
196 edit.problem = problem
197 edit.status = FAILED
198 return edit
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:]
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]
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 ""