apply-edit-block 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apply_edit_block-0.1.0.dist-info/METADATA +164 -0
- apply_edit_block-0.1.0.dist-info/RECORD +5 -0
- apply_edit_block-0.1.0.dist-info/WHEEL +4 -0
- apply_edit_block.py +407 -0
- py.typed +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: apply-edit-block
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Apply search/replace edit blocks from coding agents to source text, with a fallback ladder for near-exact matches.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pjdurden/apply-edit-block
|
|
6
|
+
Project-URL: Source, https://github.com/pjdurden/apply-edit-block
|
|
7
|
+
Author: Prajjwal Chittori
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: coding-agent,diff,edit,fuzzy-match,patch,search-replace
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# apply-edit-block
|
|
19
|
+
|
|
20
|
+
Apply search/replace edit blocks from coding agents to source text, with a fallback ladder for near-exact matches. This is the Python port of the [JavaScript `apply-edit-block` package](https://github.com/pjdurden/apply-edit-block).
|
|
21
|
+
|
|
22
|
+
## The problem
|
|
23
|
+
|
|
24
|
+
Coding agents (and the humans steering them) emit edits as a block of "find this text, replace it with this text." The search text is almost never byte-exact: the model drops trailing whitespace, reindents a block, paraphrases a comment, or gets one word wrong in an otherwise correct match. Aider, Cline, Roo, Continue, and a pile of homegrown tools each reimplement their own ladder of fallback matching strategies to cope with this, usually as a tangle of regexes buried inside a larger apply-patch function. There are a handful of small competing packages for this and no clear winner, and none of them report which strategy actually matched so callers can log and tune it.
|
|
25
|
+
|
|
26
|
+
This package is that ladder, pulled out on its own. It matches search text against source text using five strategies of decreasing strictness, applies the replacement, and reports which strategy it used and how confident the match was. It does the matching only: no filesystem access, no git, no diff generation.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install apply-edit-block
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from apply_edit_block import apply_edit, apply_edits, parse_blocks, similarity
|
|
38
|
+
|
|
39
|
+
source = '''function greet(name):
|
|
40
|
+
print("hi " + name)
|
|
41
|
+
'''
|
|
42
|
+
|
|
43
|
+
# The model dropped a trailing space, but the exact strategy still
|
|
44
|
+
# finds it via the fallback ladder.
|
|
45
|
+
edit = {
|
|
46
|
+
"search": ' print("hi " + name) ',
|
|
47
|
+
"replace": ' print(f"hi {name}")',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
result = apply_edit(source, edit)
|
|
51
|
+
print(result.strategy) # 'trailing-ws'
|
|
52
|
+
print(result.text)
|
|
53
|
+
# function greet(name):
|
|
54
|
+
# print(f"hi {name}")
|
|
55
|
+
|
|
56
|
+
# Parse the conventional fenced format agents emit and apply every block.
|
|
57
|
+
patch = '''
|
|
58
|
+
<<<<<<< SEARCH
|
|
59
|
+
print("hi " + name)
|
|
60
|
+
=======
|
|
61
|
+
print(f"hi {name}")
|
|
62
|
+
>>>>>>> REPLACE
|
|
63
|
+
'''
|
|
64
|
+
edits = parse_blocks(patch)
|
|
65
|
+
multi = apply_edits(source, edits)
|
|
66
|
+
print(multi.ok, multi.applied) # True 1
|
|
67
|
+
|
|
68
|
+
# similarity() is the same scoring function 'fuzzy' uses internally.
|
|
69
|
+
print(similarity("a\nb\nc", "a\nb\nz")) # 0.6666666666666666
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
### `apply_edit(source, edit, *, anchor_slack=2, threshold=0.85) -> EditResult`
|
|
75
|
+
|
|
76
|
+
- `source: str` - the full file contents.
|
|
77
|
+
- `edit` - an `Edit` dataclass, or a dict with `search: str` and `replace: str` keys.
|
|
78
|
+
- `anchor_slack: int` (default `2`) - for the `'anchor'` strategy, how far the source's line gap between the first/last non-empty search lines may differ from the search's.
|
|
79
|
+
- `threshold: float` (default `0.85`) - for the `'fuzzy'` strategy, the minimum similarity score required to accept a window.
|
|
80
|
+
- Returns an `EditResult`, a frozen dataclass:
|
|
81
|
+
```python
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class EditResult:
|
|
84
|
+
ok: bool
|
|
85
|
+
text: str # edited source on success, ORIGINAL source on failure
|
|
86
|
+
strategy: Optional[str] # 'exact' | 'trailing-ws' | 'indent' | 'anchor' | 'fuzzy' | 'empty-search' | None
|
|
87
|
+
similarity: float # 1 on exact match; best similarity found otherwise (0..1)
|
|
88
|
+
start: int # char offset of match start in the original source, -1 on failure
|
|
89
|
+
end: int # char offset of match end (exclusive) in the original source, -1 on failure
|
|
90
|
+
```
|
|
91
|
+
- Raises `TypeError` only if `source` or the edit's `search` is not a string. A failed match never raises; it returns `ok=False`.
|
|
92
|
+
- An empty `search` string means "prepend `replace` to the file": `ok=True`, `strategy='empty-search'`, `start=0`, `end=0`.
|
|
93
|
+
|
|
94
|
+
The strategies are tried in this order, stopping at the first success:
|
|
95
|
+
|
|
96
|
+
1. **`exact`** - plain substring search.
|
|
97
|
+
2. **`trailing-ws`** - line-by-line comparison with trailing whitespace stripped from every line on both sides.
|
|
98
|
+
3. **`indent`** - line-by-line comparison with each line's leading whitespace stripped. On success, the indent delta (the matched source line's indentation minus the search's first line's indentation) is applied to every line of the replacement: add spaces for a positive delta, strip up to that many leading spaces for a negative one. Blank replacement lines are left blank.
|
|
99
|
+
4. **`anchor`** - matches only on the first and last non-empty lines of `search`, and requires the line gap between them in the source to be within `anchor_slack` of the search's gap. Useful when an interior line was paraphrased.
|
|
100
|
+
5. **`fuzzy`** - slides a window the size of `search`'s line count over the source and scores each window with `similarity()`; the best-scoring window is accepted if its score is at least `threshold`.
|
|
101
|
+
|
|
102
|
+
On failure, `similarity` reports the best score `fuzzy` saw while sliding, so callers can tune `threshold`.
|
|
103
|
+
|
|
104
|
+
### `apply_edits(source, edits, *, anchor_slack=2, threshold=0.85) -> MultiResult`
|
|
105
|
+
|
|
106
|
+
Applies a list of edits in order, each to the output of the previous one.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class MultiResult:
|
|
111
|
+
ok: bool
|
|
112
|
+
text: str
|
|
113
|
+
results: List[EditResult]
|
|
114
|
+
applied: int
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`ok` is true only if every edit applied. On the first failure it stops immediately: `text` is the source as of the last successful edit, `results` holds one `EditResult` per edit attempted (including the failing one), and `applied` is the count that succeeded.
|
|
118
|
+
|
|
119
|
+
### `parse_blocks(text) -> List[Edit]`
|
|
120
|
+
|
|
121
|
+
Parses the conventional fenced format:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
<<<<<<< SEARCH
|
|
125
|
+
old code
|
|
126
|
+
=======
|
|
127
|
+
new code
|
|
128
|
+
>>>>>>> REPLACE
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Marker lines are matched by prefix (`<{3,}`, `={3,}`, `>{3,}`), so 3 or more marker characters and trailing text on the marker line (`<<<<<<< SEARCH`, `======= divider`) are both tolerated. Text outside a block is ignored. Returns `[]` when there are no blocks. A block that opens but never closes (no matching `>>>>>>>` line before the text ends, or before a new `<<<<<<<` line starts another block) is skipped, not treated as an error.
|
|
132
|
+
|
|
133
|
+
Returns a list of `Edit` dataclass instances (not dicts):
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class Edit:
|
|
138
|
+
search: str
|
|
139
|
+
replace: str
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`Edit` instances and plain `{"search": ..., "replace": ...}` dicts are interchangeable everywhere an `edit` argument is accepted, so the output of `parse_blocks` can be passed straight into `apply_edit` / `apply_edits`, and so can your own dicts.
|
|
143
|
+
|
|
144
|
+
### `similarity(a, b) -> float`
|
|
145
|
+
|
|
146
|
+
Normalized line-level similarity between two strings, `0..1`. This is the exact function the `'fuzzy'` strategy uses internally, exported so callers can score candidate matches themselves or tune `threshold` against real data.
|
|
147
|
+
|
|
148
|
+
## How it works
|
|
149
|
+
|
|
150
|
+
`similarity()` splits both strings on `\n` and computes the longest common subsequence (LCS) of the two line arrays, using exact string equality per line, then divides by the length of the longer array. This is a classic O(n*m) dynamic-programming LCS, not a character-level edit distance. That tradeoff is deliberate: it is cheap to reason about and it is what makes the `'fuzzy'` strategy tolerate one bad line out of ten (LCS of 9, divided by 10, is 0.9) without needing a fuzzy string-distance library.
|
|
151
|
+
|
|
152
|
+
The tradeoff has a real limit: a line that differs by even one character (extra indentation, a changed variable name, a dropped semicolon) counts as a total non-match for that line in the LCS, since comparison is exact-string, not per-character. That is why `'indent'` and `'trailing-ws'` exist as their own strategies rather than being folded into `'fuzzy'`: they normalize a specific, common kind of per-line noise before comparing, so a whole block that only differs in leading or trailing whitespace still counts as fully matched rather than scoring low on `similarity`.
|
|
153
|
+
|
|
154
|
+
The `'anchor'` strategy is the loosest exact-match strategy: it trusts only the first and last non-empty lines of `search` and a line-count budget for what's in between, so it can survive a paraphrased comment or a rewritten line in the middle of an otherwise-recognizable block. It does not use `similarity` at all.
|
|
155
|
+
|
|
156
|
+
All offsets (`start`, `end`) are character indices into the original `source` string that was passed in, and `end` is exclusive, so `source[start:end]` is always the exact text that was replaced.
|
|
157
|
+
|
|
158
|
+
This module has zero runtime dependencies and is a single file (`apply_edit_block.py`).
|
|
159
|
+
|
|
160
|
+
The original JavaScript version, with the same behavior, lives at the repository root: [`../index.js`](../index.js).
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
apply_edit_block.py,sha256=ayf3v92RX3PitQv2VKK3sK83KSZBWI_Ldr4BS79QgdQ,12711
|
|
2
|
+
py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
apply_edit_block-0.1.0.dist-info/METADATA,sha256=Er_Gxh42VxAYcvBdbBCrxNUsTIPsiMccW4BFGYDmlvk,9121
|
|
4
|
+
apply_edit_block-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
apply_edit_block-0.1.0.dist-info/RECORD,,
|
apply_edit_block.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apply-edit-block
|
|
3
|
+
A standalone strategy ladder for applying "search/replace" edit blocks
|
|
4
|
+
(the kind coding agents emit) to source text, with graceful fallbacks
|
|
5
|
+
when the search text is not byte-exact.
|
|
6
|
+
|
|
7
|
+
This is a Python port of the JavaScript package `apply-edit-block`. See
|
|
8
|
+
the JavaScript source (index.js) at the repository root for the
|
|
9
|
+
original implementation; this module matches its behavior exactly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Dict, List, NamedTuple, Optional, Union
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Edit",
|
|
20
|
+
"EditResult",
|
|
21
|
+
"MultiResult",
|
|
22
|
+
"apply_edit",
|
|
23
|
+
"apply_edits",
|
|
24
|
+
"parse_blocks",
|
|
25
|
+
"similarity",
|
|
26
|
+
"DEFAULT_ANCHOR_SLACK",
|
|
27
|
+
"DEFAULT_THRESHOLD",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Internal defaults. Plain data, edit freely if you fork this file.
|
|
31
|
+
DEFAULT_ANCHOR_SLACK = 2
|
|
32
|
+
DEFAULT_THRESHOLD = 0.85
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Edit:
|
|
37
|
+
"""A single search/replace edit block."""
|
|
38
|
+
|
|
39
|
+
search: str
|
|
40
|
+
replace: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class EditResult:
|
|
45
|
+
"""The result of applying a single edit block."""
|
|
46
|
+
|
|
47
|
+
ok: bool
|
|
48
|
+
text: str
|
|
49
|
+
strategy: Optional[str]
|
|
50
|
+
similarity: float
|
|
51
|
+
start: int
|
|
52
|
+
end: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class MultiResult:
|
|
57
|
+
"""The result of applying a sequence of edit blocks."""
|
|
58
|
+
|
|
59
|
+
ok: bool
|
|
60
|
+
text: str
|
|
61
|
+
results: List[EditResult] = field(default_factory=list)
|
|
62
|
+
applied: int = 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
EditLike = Union[Edit, Dict[str, Any]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Line(NamedTuple):
|
|
69
|
+
"""A source line with its char offsets: source[start:end] == text."""
|
|
70
|
+
|
|
71
|
+
text: str
|
|
72
|
+
start: int
|
|
73
|
+
end: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_TRAILING_WS_RE = re.compile(r"[ \t]+$")
|
|
77
|
+
_LEADING_WS_RE = re.compile(r"^[ \t]+")
|
|
78
|
+
_LEADING_WS_MATCH_RE = re.compile(r"^[ \t]*")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _strip_trailing(line: str) -> str:
|
|
82
|
+
"""Strip trailing spaces/tabs from a line."""
|
|
83
|
+
return _TRAILING_WS_RE.sub("", line)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _strip_leading(line: str) -> str:
|
|
87
|
+
"""Strip leading spaces/tabs from a line."""
|
|
88
|
+
return _LEADING_WS_RE.sub("", line)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _leading_ws(line: str) -> str:
|
|
92
|
+
"""Return the leading whitespace substring of a line."""
|
|
93
|
+
m = _LEADING_WS_MATCH_RE.match(line)
|
|
94
|
+
return m.group(0) if m else ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _get_lines(source: str) -> List[_Line]:
|
|
98
|
+
"""Split source into line records with char offsets, so a matched
|
|
99
|
+
line range maps to [start, end)."""
|
|
100
|
+
lines: List[_Line] = []
|
|
101
|
+
offset = 0
|
|
102
|
+
for text in source.split("\n"):
|
|
103
|
+
end = offset + len(text)
|
|
104
|
+
lines.append(_Line(text=text, start=offset, end=end))
|
|
105
|
+
offset = end + 1 # account for the '\n' removed by split
|
|
106
|
+
return lines
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_result(
|
|
110
|
+
source: str,
|
|
111
|
+
replace_text: str,
|
|
112
|
+
strategy: str,
|
|
113
|
+
start: int,
|
|
114
|
+
end: int,
|
|
115
|
+
sim: float,
|
|
116
|
+
) -> EditResult:
|
|
117
|
+
"""Build a successful EditResult by splicing replace_text into
|
|
118
|
+
source at [start, end)."""
|
|
119
|
+
text = source[:start] + replace_text + source[end:]
|
|
120
|
+
return EditResult(ok=True, text=text, strategy=strategy, similarity=sim, start=start, end=end)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _SlideHit(NamedTuple):
|
|
124
|
+
index: int
|
|
125
|
+
start: int
|
|
126
|
+
end: int
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _slide_match(
|
|
130
|
+
source_lines: List[_Line], search_lines: List[str], normalize
|
|
131
|
+
) -> Optional[_SlideHit]:
|
|
132
|
+
"""Slide a window matching search_lines against source_lines
|
|
133
|
+
line-by-line under a comparator."""
|
|
134
|
+
n = len(search_lines)
|
|
135
|
+
if len(source_lines) < n:
|
|
136
|
+
return None
|
|
137
|
+
normalized_search = [normalize(l) for l in search_lines]
|
|
138
|
+
for i in range(0, len(source_lines) - n + 1):
|
|
139
|
+
matched = True
|
|
140
|
+
for j in range(n):
|
|
141
|
+
if normalize(source_lines[i + j].text) != normalized_search[j]:
|
|
142
|
+
matched = False
|
|
143
|
+
break
|
|
144
|
+
if matched:
|
|
145
|
+
return _SlideHit(index=i, start=source_lines[i].start, end=source_lines[i + n - 1].end)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _IndentHit(NamedTuple):
|
|
150
|
+
start: int
|
|
151
|
+
end: int
|
|
152
|
+
delta: int
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _try_indent(source_lines: List[_Line], search_lines: List[str]) -> Optional[_IndentHit]:
|
|
156
|
+
"""Strategy 3: 'indent' - compare with leading whitespace stripped,
|
|
157
|
+
then compute the shift."""
|
|
158
|
+
hit = _slide_match(source_lines, search_lines, _strip_leading)
|
|
159
|
+
if hit is None:
|
|
160
|
+
return None
|
|
161
|
+
source_indent = len(_leading_ws(source_lines[hit.index].text))
|
|
162
|
+
search_indent = len(_leading_ws(search_lines[0]))
|
|
163
|
+
return _IndentHit(start=hit.start, end=hit.end, delta=source_indent - search_indent)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _apply_indent_delta(replace: str, delta: int) -> str:
|
|
167
|
+
"""Apply a uniform indent shift to every non-blank line of the
|
|
168
|
+
replacement."""
|
|
169
|
+
if delta == 0:
|
|
170
|
+
return replace
|
|
171
|
+
|
|
172
|
+
def shift_line(line: str) -> str:
|
|
173
|
+
if line.strip() == "":
|
|
174
|
+
return line
|
|
175
|
+
if delta > 0:
|
|
176
|
+
return " " * delta + line
|
|
177
|
+
return line[min(-delta, len(_leading_ws(line))):]
|
|
178
|
+
|
|
179
|
+
return "\n".join(shift_line(l) for l in replace.split("\n"))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _AnchorHit(NamedTuple):
|
|
183
|
+
start: int
|
|
184
|
+
end: int
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _try_anchor(
|
|
188
|
+
source_lines: List[_Line], search_lines: List[str], anchor_slack: int
|
|
189
|
+
) -> Optional[_AnchorHit]:
|
|
190
|
+
"""Strategy 4: 'anchor' - match on the first/last non-empty search
|
|
191
|
+
lines with slack on the gap."""
|
|
192
|
+
non_empty_idxs = [i for i, l in enumerate(search_lines) if l.strip() != ""]
|
|
193
|
+
if not non_empty_idxs:
|
|
194
|
+
return None
|
|
195
|
+
first_idx = non_empty_idxs[0]
|
|
196
|
+
last_idx = non_empty_idxs[-1]
|
|
197
|
+
first_line = search_lines[first_idx].strip()
|
|
198
|
+
last_line = search_lines[last_idx].strip()
|
|
199
|
+
expected_gap = last_idx - first_idx
|
|
200
|
+
for i in range(len(source_lines)):
|
|
201
|
+
if source_lines[i].text.strip() != first_line:
|
|
202
|
+
continue
|
|
203
|
+
for j in range(i, len(source_lines)):
|
|
204
|
+
if source_lines[j].text.strip() != last_line:
|
|
205
|
+
continue
|
|
206
|
+
if abs(j - i - expected_gap) <= anchor_slack:
|
|
207
|
+
return _AnchorHit(start=source_lines[i].start, end=source_lines[j].end)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _lcs_length(a: List[str], b: List[str]) -> int:
|
|
212
|
+
"""Longest common subsequence length between two arrays, by
|
|
213
|
+
element equality."""
|
|
214
|
+
n = len(a)
|
|
215
|
+
m = len(b)
|
|
216
|
+
dp = [[0] * (m + 1) for _ in range(n + 1)]
|
|
217
|
+
for i in range(1, n + 1):
|
|
218
|
+
for j in range(1, m + 1):
|
|
219
|
+
if a[i - 1] == b[j - 1]:
|
|
220
|
+
dp[i][j] = dp[i - 1][j - 1] + 1
|
|
221
|
+
else:
|
|
222
|
+
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
|
|
223
|
+
return dp[n][m]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def similarity(a: str, b: str) -> float:
|
|
227
|
+
"""Normalized line-level similarity between two strings, 0..1.
|
|
228
|
+
|
|
229
|
+
Computed as the longest-common-subsequence over the two line
|
|
230
|
+
arrays, divided by the longer array's length. Used by the 'fuzzy'
|
|
231
|
+
strategy.
|
|
232
|
+
"""
|
|
233
|
+
lines_a = a.split("\n")
|
|
234
|
+
lines_b = b.split("\n")
|
|
235
|
+
max_len = max(len(lines_a), len(lines_b))
|
|
236
|
+
return _lcs_length(lines_a, lines_b) / max_len
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class _FuzzyResult(NamedTuple):
|
|
240
|
+
match: Optional[_AnchorHit]
|
|
241
|
+
best_score: float
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _try_fuzzy(source_lines: List[_Line], search_lines: List[str], threshold: float) -> _FuzzyResult:
|
|
245
|
+
"""Strategy 5: 'fuzzy' - slide a window and score it against the
|
|
246
|
+
search text."""
|
|
247
|
+
search_text = "\n".join(search_lines)
|
|
248
|
+
win_size = len(search_lines)
|
|
249
|
+
if len(source_lines) < win_size:
|
|
250
|
+
whole = "\n".join(l.text for l in source_lines)
|
|
251
|
+
return _FuzzyResult(match=None, best_score=similarity(search_text, whole))
|
|
252
|
+
best_score = -1.0
|
|
253
|
+
best_index = -1
|
|
254
|
+
for i in range(0, len(source_lines) - win_size + 1):
|
|
255
|
+
window_text = "\n".join(l.text for l in source_lines[i : i + win_size])
|
|
256
|
+
score = similarity(search_text, window_text)
|
|
257
|
+
if score > best_score:
|
|
258
|
+
best_score = score
|
|
259
|
+
best_index = i
|
|
260
|
+
if best_score >= threshold:
|
|
261
|
+
match = _AnchorHit(start=source_lines[best_index].start, end=source_lines[best_index + win_size - 1].end)
|
|
262
|
+
return _FuzzyResult(match=match, best_score=best_score)
|
|
263
|
+
return _FuzzyResult(match=None, best_score=best_score)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _field(edit: EditLike, name: str) -> Any:
|
|
267
|
+
"""Read `name` off an Edit dataclass or a dict, mirroring JS's
|
|
268
|
+
permissive property access on edit.search / edit.replace."""
|
|
269
|
+
if isinstance(edit, dict):
|
|
270
|
+
return edit.get(name)
|
|
271
|
+
return getattr(edit, name, None)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def apply_edit(
|
|
275
|
+
source: str,
|
|
276
|
+
edit: EditLike,
|
|
277
|
+
*,
|
|
278
|
+
anchor_slack: int = DEFAULT_ANCHOR_SLACK,
|
|
279
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
280
|
+
) -> EditResult:
|
|
281
|
+
"""Apply one search/replace edit block to source text, trying a
|
|
282
|
+
ladder of matching strategies from strict to fuzzy and stopping at
|
|
283
|
+
the first success.
|
|
284
|
+
|
|
285
|
+
`edit` may be an `Edit` dataclass or a dict with `search`/`replace`
|
|
286
|
+
keys.
|
|
287
|
+
|
|
288
|
+
Raises `TypeError` if `source` or the search text is not a string.
|
|
289
|
+
A failed match never raises; it returns `ok=False` with the
|
|
290
|
+
original `source` in `text`.
|
|
291
|
+
"""
|
|
292
|
+
if not isinstance(source, str):
|
|
293
|
+
raise TypeError("source must be a string")
|
|
294
|
+
search = _field(edit, "search") if edit is not None else None
|
|
295
|
+
if edit is None or not isinstance(search, str):
|
|
296
|
+
raise TypeError("edit.search must be a string")
|
|
297
|
+
|
|
298
|
+
replace_raw = _field(edit, "replace")
|
|
299
|
+
replace = replace_raw if isinstance(replace_raw, str) else ""
|
|
300
|
+
|
|
301
|
+
if search == "":
|
|
302
|
+
return EditResult(ok=True, text=replace + source, strategy="empty-search", similarity=1, start=0, end=0)
|
|
303
|
+
|
|
304
|
+
# 1. exact
|
|
305
|
+
exact_idx = source.find(search)
|
|
306
|
+
if exact_idx != -1:
|
|
307
|
+
return _build_result(source, replace, "exact", exact_idx, exact_idx + len(search), 1)
|
|
308
|
+
|
|
309
|
+
source_lines = _get_lines(source)
|
|
310
|
+
search_lines = search.split("\n")
|
|
311
|
+
|
|
312
|
+
# 2. trailing-ws
|
|
313
|
+
tw = _slide_match(source_lines, search_lines, _strip_trailing)
|
|
314
|
+
if tw is not None:
|
|
315
|
+
sim = similarity(search, source[tw.start : tw.end])
|
|
316
|
+
return _build_result(source, replace, "trailing-ws", tw.start, tw.end, sim)
|
|
317
|
+
|
|
318
|
+
# 3. indent
|
|
319
|
+
ind = _try_indent(source_lines, search_lines)
|
|
320
|
+
if ind is not None:
|
|
321
|
+
sim = similarity(search, source[ind.start : ind.end])
|
|
322
|
+
adjusted = _apply_indent_delta(replace, ind.delta)
|
|
323
|
+
return _build_result(source, adjusted, "indent", ind.start, ind.end, sim)
|
|
324
|
+
|
|
325
|
+
# 4. anchor
|
|
326
|
+
anc = _try_anchor(source_lines, search_lines, anchor_slack)
|
|
327
|
+
if anc is not None:
|
|
328
|
+
sim = similarity(search, source[anc.start : anc.end])
|
|
329
|
+
return _build_result(source, replace, "anchor", anc.start, anc.end, sim)
|
|
330
|
+
|
|
331
|
+
# 5. fuzzy
|
|
332
|
+
fz = _try_fuzzy(source_lines, search_lines, threshold)
|
|
333
|
+
if fz.match is not None:
|
|
334
|
+
sim = similarity(search, source[fz.match.start : fz.match.end])
|
|
335
|
+
return _build_result(source, replace, "fuzzy", fz.match.start, fz.match.end, sim)
|
|
336
|
+
|
|
337
|
+
return EditResult(ok=False, text=source, strategy=None, similarity=max(fz.best_score, 0), start=-1, end=-1)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def apply_edits(
|
|
341
|
+
source: str,
|
|
342
|
+
edits: List[EditLike],
|
|
343
|
+
*,
|
|
344
|
+
anchor_slack: int = DEFAULT_ANCHOR_SLACK,
|
|
345
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
346
|
+
) -> MultiResult:
|
|
347
|
+
"""Apply a list of edits in order, each to the output of the
|
|
348
|
+
previous one. Stops at the first failure."""
|
|
349
|
+
text = source
|
|
350
|
+
results: List[EditResult] = []
|
|
351
|
+
applied = 0
|
|
352
|
+
for edit in edits:
|
|
353
|
+
result = apply_edit(text, edit, anchor_slack=anchor_slack, threshold=threshold)
|
|
354
|
+
results.append(result)
|
|
355
|
+
if not result.ok:
|
|
356
|
+
return MultiResult(ok=False, text=text, results=results, applied=applied)
|
|
357
|
+
text = result.text
|
|
358
|
+
applied += 1
|
|
359
|
+
return MultiResult(ok=True, text=text, results=results, applied=applied)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
_START_RE = re.compile(r"^<{3,}")
|
|
363
|
+
_DIVIDER_RE = re.compile(r"^={3,}")
|
|
364
|
+
_END_RE = re.compile(r"^>{3,}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def parse_blocks(text: str) -> List[Edit]:
|
|
368
|
+
"""Parse the conventional fenced search/replace block format:
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
<<<<<<< SEARCH
|
|
372
|
+
old code
|
|
373
|
+
=======
|
|
374
|
+
new code
|
|
375
|
+
>>>>>>> REPLACE
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Tolerates 3+ marker characters and trailing text on marker lines.
|
|
379
|
+
An unclosed block is skipped rather than treated as an error.
|
|
380
|
+
|
|
381
|
+
Returns a list of `Edit` dataclass instances (not dicts).
|
|
382
|
+
"""
|
|
383
|
+
lines = text.split("\n")
|
|
384
|
+
blocks: List[Edit] = []
|
|
385
|
+
state = "none" # 'none' | 'search' | 'replace'
|
|
386
|
+
search_lines: List[str] = []
|
|
387
|
+
replace_lines: List[str] = []
|
|
388
|
+
for line in lines:
|
|
389
|
+
if _START_RE.match(line):
|
|
390
|
+
state = "search"
|
|
391
|
+
search_lines = []
|
|
392
|
+
replace_lines = []
|
|
393
|
+
continue
|
|
394
|
+
if state == "search":
|
|
395
|
+
if _DIVIDER_RE.match(line):
|
|
396
|
+
state = "replace"
|
|
397
|
+
else:
|
|
398
|
+
search_lines.append(line)
|
|
399
|
+
continue
|
|
400
|
+
if state == "replace":
|
|
401
|
+
if _END_RE.match(line):
|
|
402
|
+
blocks.append(Edit(search="\n".join(search_lines), replace="\n".join(replace_lines)))
|
|
403
|
+
state = "none"
|
|
404
|
+
else:
|
|
405
|
+
replace_lines.append(line)
|
|
406
|
+
|
|
407
|
+
return blocks
|
py.typed
ADDED
|
File without changes
|