apply-edit-block 0.1.0__tar.gz

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.
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ *.log
3
+ .DS_Store
@@ -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,147 @@
1
+ # apply-edit-block
2
+
3
+ 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).
4
+
5
+ ## The problem
6
+
7
+ 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.
8
+
9
+ 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.
10
+
11
+ ## Install
12
+
13
+ ```
14
+ pip install apply-edit-block
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from apply_edit_block import apply_edit, apply_edits, parse_blocks, similarity
21
+
22
+ source = '''function greet(name):
23
+ print("hi " + name)
24
+ '''
25
+
26
+ # The model dropped a trailing space, but the exact strategy still
27
+ # finds it via the fallback ladder.
28
+ edit = {
29
+ "search": ' print("hi " + name) ',
30
+ "replace": ' print(f"hi {name}")',
31
+ }
32
+
33
+ result = apply_edit(source, edit)
34
+ print(result.strategy) # 'trailing-ws'
35
+ print(result.text)
36
+ # function greet(name):
37
+ # print(f"hi {name}")
38
+
39
+ # Parse the conventional fenced format agents emit and apply every block.
40
+ patch = '''
41
+ <<<<<<< SEARCH
42
+ print("hi " + name)
43
+ =======
44
+ print(f"hi {name}")
45
+ >>>>>>> REPLACE
46
+ '''
47
+ edits = parse_blocks(patch)
48
+ multi = apply_edits(source, edits)
49
+ print(multi.ok, multi.applied) # True 1
50
+
51
+ # similarity() is the same scoring function 'fuzzy' uses internally.
52
+ print(similarity("a\nb\nc", "a\nb\nz")) # 0.6666666666666666
53
+ ```
54
+
55
+ ## API
56
+
57
+ ### `apply_edit(source, edit, *, anchor_slack=2, threshold=0.85) -> EditResult`
58
+
59
+ - `source: str` - the full file contents.
60
+ - `edit` - an `Edit` dataclass, or a dict with `search: str` and `replace: str` keys.
61
+ - `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.
62
+ - `threshold: float` (default `0.85`) - for the `'fuzzy'` strategy, the minimum similarity score required to accept a window.
63
+ - Returns an `EditResult`, a frozen dataclass:
64
+ ```python
65
+ @dataclass(frozen=True)
66
+ class EditResult:
67
+ ok: bool
68
+ text: str # edited source on success, ORIGINAL source on failure
69
+ strategy: Optional[str] # 'exact' | 'trailing-ws' | 'indent' | 'anchor' | 'fuzzy' | 'empty-search' | None
70
+ similarity: float # 1 on exact match; best similarity found otherwise (0..1)
71
+ start: int # char offset of match start in the original source, -1 on failure
72
+ end: int # char offset of match end (exclusive) in the original source, -1 on failure
73
+ ```
74
+ - Raises `TypeError` only if `source` or the edit's `search` is not a string. A failed match never raises; it returns `ok=False`.
75
+ - An empty `search` string means "prepend `replace` to the file": `ok=True`, `strategy='empty-search'`, `start=0`, `end=0`.
76
+
77
+ The strategies are tried in this order, stopping at the first success:
78
+
79
+ 1. **`exact`** - plain substring search.
80
+ 2. **`trailing-ws`** - line-by-line comparison with trailing whitespace stripped from every line on both sides.
81
+ 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.
82
+ 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.
83
+ 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`.
84
+
85
+ On failure, `similarity` reports the best score `fuzzy` saw while sliding, so callers can tune `threshold`.
86
+
87
+ ### `apply_edits(source, edits, *, anchor_slack=2, threshold=0.85) -> MultiResult`
88
+
89
+ Applies a list of edits in order, each to the output of the previous one.
90
+
91
+ ```python
92
+ @dataclass(frozen=True)
93
+ class MultiResult:
94
+ ok: bool
95
+ text: str
96
+ results: List[EditResult]
97
+ applied: int
98
+ ```
99
+
100
+ `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.
101
+
102
+ ### `parse_blocks(text) -> List[Edit]`
103
+
104
+ Parses the conventional fenced format:
105
+
106
+ ```
107
+ <<<<<<< SEARCH
108
+ old code
109
+ =======
110
+ new code
111
+ >>>>>>> REPLACE
112
+ ```
113
+
114
+ 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.
115
+
116
+ Returns a list of `Edit` dataclass instances (not dicts):
117
+
118
+ ```python
119
+ @dataclass(frozen=True)
120
+ class Edit:
121
+ search: str
122
+ replace: str
123
+ ```
124
+
125
+ `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.
126
+
127
+ ### `similarity(a, b) -> float`
128
+
129
+ 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.
130
+
131
+ ## How it works
132
+
133
+ `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.
134
+
135
+ 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`.
136
+
137
+ 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.
138
+
139
+ 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.
140
+
141
+ This module has zero runtime dependencies and is a single file (`apply_edit_block.py`).
142
+
143
+ The original JavaScript version, with the same behavior, lives at the repository root: [`../index.js`](../index.js).
144
+
145
+ ## License
146
+
147
+ MIT
@@ -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
File without changes
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apply-edit-block"
7
+ version = "0.1.0"
8
+ description = "Apply search/replace edit blocks from coding agents to source text, with a fallback ladder for near-exact matches."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Prajjwal Chittori"}]
13
+ keywords = ["edit", "patch", "diff", "search-replace", "coding-agent", "fuzzy-match"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/pjdurden/apply-edit-block"
24
+ Source = "https://github.com/pjdurden/apply-edit-block"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ include = ["apply_edit_block.py", "py.typed"]
@@ -0,0 +1,234 @@
1
+ """Tests for apply_edit_block, ported one for one from the JavaScript
2
+ test suite at ../test/apply-edit-block.test.js, plus the extra indent
3
+ cases specified for this port."""
4
+
5
+ import unittest
6
+
7
+ from apply_edit_block import Edit, apply_edit, apply_edits, parse_blocks, similarity
8
+
9
+
10
+ class ApplyEditTest(unittest.TestCase):
11
+ def test_exact_match_reports_strategy_exact_and_similarity_1(self):
12
+ source = "const x = 1;\nconst y = 2;\n"
13
+ result = apply_edit(source, {"search": "const x = 1;\n", "replace": "const x = 100;\n"})
14
+ self.assertEqual(result.ok, True)
15
+ self.assertEqual(result.strategy, "exact")
16
+ self.assertEqual(result.similarity, 1)
17
+ self.assertEqual(result.text, "const x = 100;\nconst y = 2;\n")
18
+ self.assertEqual(result.start, 0)
19
+ self.assertEqual(result.end, 13)
20
+
21
+ def test_trailing_whitespace_in_search_matches_via_trailing_ws(self):
22
+ source = "function foo() {\n return 1;\n}\n"
23
+ search = "function foo() { \n return 1; \n}\n"
24
+ result = apply_edit(source, {"search": search, "replace": "function foo() {\n return 2;\n}\n"})
25
+ self.assertEqual(result.ok, True)
26
+ self.assertEqual(result.strategy, "trailing-ws")
27
+ self.assertEqual(result.text, "function foo() {\n return 2;\n}\n")
28
+
29
+ def test_indent_mismatch_matches_via_indent_and_reapplies_source_indentation(self):
30
+ source = " if (x) {\n doThing();\n }\n"
31
+ search = " if (x) {\n doThing();\n }"
32
+ replace = " if (x) {\n doOther();\n\n doMore();\n }"
33
+ result = apply_edit(source, {"search": search, "replace": replace})
34
+ self.assertEqual(result.ok, True)
35
+ self.assertEqual(result.strategy, "indent")
36
+ self.assertEqual(
37
+ result.text,
38
+ " if (x) {\n doOther();\n\n doMore();\n }\n",
39
+ )
40
+
41
+ def test_indent_strategy_leaves_blank_replacement_lines_blank(self):
42
+ source = " a();\n b();\n"
43
+ search = "a();\nb();"
44
+ replace = "a();\n\nb();"
45
+ result = apply_edit(source, {"search": search, "replace": replace})
46
+ self.assertEqual(result.strategy, "indent")
47
+ self.assertEqual(result.text, " a();\n\n b();\n")
48
+
49
+ def test_indent_strategy_handles_negative_delta_by_stripping_leading_spaces(self):
50
+ source = "a();\nb();\n"
51
+ search = " a();\n b();"
52
+ replace = " a();\n c();"
53
+ result = apply_edit(source, {"search": search, "replace": replace})
54
+ self.assertEqual(result.strategy, "indent")
55
+ self.assertEqual(result.text, "a();\nc();\n")
56
+
57
+ def test_anchor_strategy_matches_on_first_last_nonempty_lines_when_middle_differs(self):
58
+ source = "\n".join(
59
+ [
60
+ "function calc(a, b) {",
61
+ " const sum = a + b;",
62
+ " const product = a * b;",
63
+ " return sum + product;",
64
+ "}",
65
+ ]
66
+ )
67
+ search = "\n".join(
68
+ [
69
+ "function calc(a, b) {",
70
+ " // a different comment entirely",
71
+ " return sum + product;",
72
+ "}",
73
+ ]
74
+ )
75
+ result = apply_edit(source, {"search": search, "replace": "function calc(a, b) {\n return 0;\n}"})
76
+ self.assertEqual(result.ok, True)
77
+ self.assertEqual(result.strategy, "anchor")
78
+ self.assertEqual(result.text, "function calc(a, b) {\n return 0;\n}")
79
+
80
+ def test_anchor_strategy_respects_anchor_slack_on_line_count_gap(self):
81
+ source = "\n".join(["START", "a", "b", "c", "d", "e", "END"])
82
+ search = "\n".join(["START", "x", "END"]) # gap 1 in search vs gap 5 in source
83
+ failure = apply_edit(source, {"search": search, "replace": "GONE"}, anchor_slack=2)
84
+ self.assertEqual(failure.strategy, None)
85
+ success = apply_edit(source, {"search": search, "replace": "GONE"}, anchor_slack=5)
86
+ self.assertEqual(success.strategy, "anchor")
87
+ self.assertEqual(success.text, "GONE")
88
+
89
+ def test_one_word_typo_inside_10_line_block_matches_via_fuzzy(self):
90
+ lines = [f" line{i} = {i};" for i in range(1, 11)]
91
+ source = "\n".join(lines)
92
+ search_lines = lines[:]
93
+ search_lines[0] = search_lines[0].replace("line1", "lyne1") # typo breaks the anchor too
94
+ result = apply_edit(source, {"search": "\n".join(search_lines), "replace": "REPLACED_BLOCK"})
95
+ self.assertEqual(result.ok, True)
96
+ self.assertEqual(result.strategy, "fuzzy")
97
+ self.assertEqual(result.text, "REPLACED_BLOCK")
98
+ self.assertTrue(result.similarity >= 0.85 and result.similarity < 1)
99
+
100
+ def test_fuzzy_strategy_respects_the_threshold_option(self):
101
+ lines = [f" line{i} = {i};" for i in range(1, 11)]
102
+ source = "\n".join(lines)
103
+ search_lines = lines[:]
104
+ search_lines[0] = "lyne1 = totally different content here"
105
+ search = "\n".join(search_lines)
106
+ result = apply_edit(source, {"search": search, "replace": "X"}, threshold=0.95)
107
+ self.assertEqual(result.ok, False)
108
+ self.assertTrue(result.similarity < 0.95)
109
+
110
+ def test_garbage_search_text_fails_cleanly(self):
111
+ source = "const a = 1;\nconst b = 2;\n"
112
+ result = apply_edit(source, {"search": "totally unrelated garbage §§§", "replace": "x"})
113
+ self.assertEqual(result.ok, False)
114
+ self.assertEqual(result.text, source)
115
+ self.assertEqual(result.strategy, None)
116
+ self.assertEqual(result.start, -1)
117
+ self.assertEqual(result.end, -1)
118
+ self.assertTrue(result.similarity >= 0 and result.similarity < 1)
119
+
120
+ def test_empty_search_string_prepends_the_replacement(self):
121
+ source = "body\n"
122
+ result = apply_edit(source, {"search": "", "replace": "header\n"})
123
+ self.assertEqual(result.ok, True)
124
+ self.assertEqual(result.strategy, "empty-search")
125
+ self.assertEqual(result.similarity, 1)
126
+ self.assertEqual(result.start, 0)
127
+ self.assertEqual(result.end, 0)
128
+ self.assertEqual(result.text, "header\nbody\n")
129
+
130
+ def test_apply_edit_raises_typeerror_for_non_string_source_or_search(self):
131
+ with self.assertRaises(TypeError):
132
+ apply_edit(42, {"search": "a", "replace": "b"})
133
+ with self.assertRaises(TypeError):
134
+ apply_edit("src", {"search": 42, "replace": "b"})
135
+
136
+ def test_apply_edit_accepts_edit_dataclass_as_well_as_dict(self):
137
+ source = "const x = 1;\n"
138
+ result = apply_edit(source, Edit(search="const x = 1;\n", replace="const x = 2;\n"))
139
+ self.assertEqual(result.ok, True)
140
+ self.assertEqual(result.text, "const x = 2;\n")
141
+
142
+ def test_parse_blocks_returns_two_blocks_in_order(self):
143
+ text = "\n".join(
144
+ [
145
+ "<<<<<<< SEARCH",
146
+ "foo1",
147
+ "=======",
148
+ "bar1",
149
+ ">>>>>>> REPLACE",
150
+ "unrelated text in between",
151
+ "<<<<<<<<< SEARCH extra",
152
+ "foo2",
153
+ "========= extra",
154
+ "bar2",
155
+ ">>>>>>>>> REPLACE extra",
156
+ ]
157
+ )
158
+ blocks = parse_blocks(text)
159
+ self.assertEqual(len(blocks), 2)
160
+ self.assertEqual(blocks[0], Edit(search="foo1", replace="bar1"))
161
+ self.assertEqual(blocks[1], Edit(search="foo2", replace="bar2"))
162
+
163
+ def test_parse_blocks_skips_an_unclosed_block_and_returns_empty(self):
164
+ text = "\n".join(["<<<<<<< SEARCH", "foo", "=======", "bar"])
165
+ self.assertEqual(parse_blocks(text), [])
166
+
167
+ def test_parse_blocks_returns_empty_when_there_are_no_blocks(self):
168
+ self.assertEqual(parse_blocks("just some plain text\nwith no markers"), [])
169
+
170
+ def test_apply_edits_stops_at_the_first_failure_and_reports_applied(self):
171
+ source = "aaa\nbbb\nccc\n"
172
+ edits = [
173
+ {"search": "aaa", "replace": "AAA"},
174
+ {"search": "not-there-at-all", "replace": "X"},
175
+ {"search": "ccc", "replace": "CCC"},
176
+ ]
177
+ result = apply_edits(source, edits)
178
+ self.assertEqual(result.ok, False)
179
+ self.assertEqual(result.applied, 1)
180
+ self.assertEqual(result.text, "AAA\nbbb\nccc\n")
181
+ self.assertEqual(len(result.results), 2)
182
+ self.assertEqual(result.results[0].ok, True)
183
+ self.assertEqual(result.results[1].ok, False)
184
+
185
+ def test_apply_edits_applies_every_edit_in_sequence_when_all_succeed(self):
186
+ source = "aaa\nbbb\nccc\n"
187
+ edits = [
188
+ {"search": "aaa", "replace": "AAA"},
189
+ {"search": "bbb", "replace": "BBB"},
190
+ {"search": "ccc", "replace": "CCC"},
191
+ ]
192
+ result = apply_edits(source, edits)
193
+ self.assertEqual(result.ok, True)
194
+ self.assertEqual(result.applied, 3)
195
+ self.assertEqual(result.text, "AAA\nBBB\nCCC\n")
196
+
197
+ def test_similarity_is_1_for_identical_strings_and_lower_for_different_ones(self):
198
+ self.assertEqual(similarity("a\nb\nc", "a\nb\nc"), 1)
199
+ self.assertTrue(similarity("a\nb\nc", "x\ny\nz") < 1)
200
+ self.assertEqual(similarity("", ""), 1)
201
+
202
+ def test_similarity_is_the_exact_function_used_to_score_the_fuzzy_strategy(self):
203
+ source = "\n".join([" line1 = 1;", " line2 = 2;", " line3 = 2;"])
204
+ search = "\n".join([" line1 = 1;", " line2 = 2;", " line3 = 99;"])
205
+ result = apply_edit(source, {"search": search, "replace": "X"}, threshold=0.5)
206
+ expected = similarity(search, source)
207
+ self.assertEqual(result.similarity, expected)
208
+
209
+ # --- extra indent cases specified for this port, checked character for character ---
210
+
211
+ def test_indent_strategy_positive_delta_with_nested_block_char_for_char(self):
212
+ source = " if (cond) {\n doStuff();\n }\n"
213
+ search = " if (cond) {\n doStuff();\n }"
214
+ replace = " if (cond) {\n doOther();\n\n if (nested) {\n doNested();\n }\n }"
215
+ result = apply_edit(source, {"search": search, "replace": replace})
216
+ self.assertEqual(result.strategy, "indent")
217
+ expected = (
218
+ " if (cond) {\n doOther();\n\n if (nested) {\n"
219
+ " doNested();\n }\n }\n"
220
+ )
221
+ self.assertEqual(result.text, expected)
222
+
223
+ def test_indent_strategy_negative_delta_with_nested_block_char_for_char(self):
224
+ source = " if (cond) {\n doStuff();\n }\n"
225
+ search = " if (cond) {\n doStuff();\n }"
226
+ replace = " if (cond) {\n doOther();\n\n if (nested) {\n doNested();\n }\n }"
227
+ result = apply_edit(source, {"search": search, "replace": replace})
228
+ self.assertEqual(result.strategy, "indent")
229
+ expected = " if (cond) {\n doOther();\n\n if (nested) {\n doNested();\n }\n }\n"
230
+ self.assertEqual(result.text, expected)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ unittest.main()