@khanhicetea/pi-better-tool 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +44 -0
- package/src/apply.ts +277 -0
- package/src/diagnostics.ts +350 -0
- package/src/index.ts +22 -0
- package/src/similarity.ts +249 -0
- package/src/text.ts +129 -0
- package/src/tool.ts +221 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-better-tool contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# pi-better-tool
|
|
2
|
+
|
|
3
|
+
Better built-in tools for the [pi coding agent](https://github.com/earendil-works/pi-mono) — starting with an `edit` override that turns failed edits into recoverable ones.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
The built-in `edit` tool requires `edits[].oldText` to match **exactly and uniquely**. When it doesn't, the tool fails with a bare error:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Could not find edits[1] in /code/app.go. The oldText must match exactly including all whitespace and newlines.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That failure wastes a whole loop: the model has to `read` the file again, guess a larger context, and retry — sometimes failing again. `pi-better-tool` keeps the refusal (writing the wrong occurrence would be worse) but returns **recovery context** so the next call succeeds without re-reading:
|
|
14
|
+
|
|
15
|
+
- **Ambiguous match (2+ occurrences)** — every occurrence's line number plus the **minimum prefix/suffix context** that makes each occurrence unique, rendered as ready-to-use `oldText` snippets.
|
|
16
|
+
- **Text not found** — the closest matching region (fuzzy line similarity), a line-by-line comparison against your `oldText`, the exact file bytes to retry with, and likely causes (tabs vs spaces, indentation, case).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:@khanhicetea/pi-better-tool
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For local development from this monorepo:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install /absolute/path/to/pi-kit/packages/pi-better-tool
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It is also registered in the root `package.json` under `pi.extensions`.
|
|
31
|
+
|
|
32
|
+
## Example: ambiguous oldText
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
Found 2 occurrences of the text in dup.go. The text must be unique. Please provide more context to make it unique.
|
|
36
|
+
|
|
37
|
+
Occurrences:
|
|
38
|
+
1. lines 2-3
|
|
39
|
+
2. lines 6-7
|
|
40
|
+
|
|
41
|
+
Retry with a disambiguated oldText: pick ONE occurrence below and reuse its snippet exactly. Each snippet already includes the minimum surrounding context that makes it unique:
|
|
42
|
+
|
|
43
|
+
Occurrence 1 (lines 2-3) — minimum context: 0 lines before, 1 line after:
|
|
44
|
+
```
|
|
45
|
+
log()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func second() {
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Occurrence 2 (lines 6-7) — minimum context: 0 lines before, 0 lines after:
|
|
52
|
+
```
|
|
53
|
+
log()
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Tip: use the snippet byte-for-byte as the new oldText, and make newText the snippet with your change applied (the snippet may span whole lines).
|
|
58
|
+
|
|
59
|
+
No changes were written — the file was not modified.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Example: text not found
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
Could not find the exact text in tabs.go. The old text must match exactly including all whitespace and newlines.
|
|
66
|
+
|
|
67
|
+
Closest match in the file: lines 3-5 (~91% line similarity).
|
|
68
|
+
Differences vs your oldText (2 of 3 compared lines match):
|
|
69
|
+
file line 4 differs from your oldText line 2:
|
|
70
|
+
file: →tab→fmt.Println("hi")
|
|
71
|
+
oldText: fmt.Println("hi")
|
|
72
|
+
|
|
73
|
+
Exact file content at lines 3-5 — retry using this text as oldText (then apply your change to newText):
|
|
74
|
+
```
|
|
75
|
+
func main() {
|
|
76
|
+
fmt.Println("hi")
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Possible cause:
|
|
81
|
+
- whitespace mismatch: the text matches when ALL whitespace is removed — check tabs vs spaces and indentation width
|
|
82
|
+
|
|
83
|
+
No changes were written — the file was not modified.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Behavior
|
|
87
|
+
|
|
88
|
+
Happy-path semantics are **identical** to the built-in `edit` tool:
|
|
89
|
+
|
|
90
|
+
- same schema (`path` + `edits[{oldText,newText}]`), including the compatibility shim for models that send `edits` as a JSON string, a single edit object, or legacy top-level `oldText`/`newText`
|
|
91
|
+
- same matching engine ported from pi's `edit-diff.ts`: exact match first, fuzzy fallback (trailing whitespace, smart quotes, dashes, unicode spaces), uniqueness checked in fuzzy-normalized space, all edits matched against the original content, overlap/empty/no-change detection
|
|
92
|
+
- same BOM and CRLF handling
|
|
93
|
+
- same success result shape (`details.diff` / `details.patch` / `details.firstChangedLine`), and no custom renderers — the built-in diff renderer is inherited
|
|
94
|
+
|
|
95
|
+
Failure behavior is the difference: errors carry the recovery context described above, and nothing is written on failure (edits remain atomic).
|
|
96
|
+
|
|
97
|
+
Diagnostics degrade gracefully: files over ~2 MB skip the analysis and return the plain built-in-style error; repeated blocks that cannot be disambiguated within 12 context lines get a guidance note instead of snippets.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm run check # typecheck + tests
|
|
103
|
+
npm test # vitest only
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Publishing
|
|
107
|
+
|
|
108
|
+
From the repository root, verify the package and inspect its tarball before publishing:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm run check --workspace=@khanhicetea/pi-better-tool
|
|
112
|
+
npm pack --dry-run --workspace=@khanhicetea/pi-better-tool
|
|
113
|
+
npm publish --workspace=@khanhicetea/pi-better-tool
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The package is configured for public publishing under the `@khanhicetea` scope. npm authentication is required.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@khanhicetea/pi-better-tool",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Better built-in tools for pi: an edit tool override that returns recovery context (closest match + disambiguation snippets) instead of bare failures",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/khanhicetea/pi-kit.git",
|
|
10
|
+
"directory": "packages/pi-better-tool"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/khanhicetea/pi-kit/tree/main/packages/pi-better-tool",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/khanhicetea/pi-kit/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["pi-package", "pi-extension", "edit", "tools"],
|
|
17
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"check": "npm run typecheck && npm test",
|
|
22
|
+
"prepublishOnly": "npm run check"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
26
|
+
"typebox": "*"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
30
|
+
"@types/node": "^24.0.0",
|
|
31
|
+
"typebox": "^1.0.0",
|
|
32
|
+
"typescript": "^5.9.0",
|
|
33
|
+
"vitest": "^3.2.0"
|
|
34
|
+
},
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": ["./src/index.ts"]
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/apply.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit matching and application engine.
|
|
3
|
+
*
|
|
4
|
+
* A port of pi's built-in `applyEditsToNormalizedContent` with one key
|
|
5
|
+
* difference: instead of throwing opaque errors, `analyzeEdits` returns a
|
|
6
|
+
* structured failure describing *why* an edit failed, which the diagnostics
|
|
7
|
+
* layer turns into actionable recovery context for the model.
|
|
8
|
+
*
|
|
9
|
+
* Matching semantics are kept identical to the built-in tool:
|
|
10
|
+
* - exact match first, then fuzzy-normalized fallback (trailing whitespace,
|
|
11
|
+
* smart quotes, dashes, unicode spaces)
|
|
12
|
+
* - uniqueness is always checked in fully fuzzy-normalized space
|
|
13
|
+
* - all edits match against the same original content (not incrementally)
|
|
14
|
+
* - overlap detection, empty-oldText and no-change detection
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
countFuzzyOccurrences,
|
|
19
|
+
findAllOccurrences,
|
|
20
|
+
getLineSpans,
|
|
21
|
+
lineAt,
|
|
22
|
+
normalizeForFuzzyMatch,
|
|
23
|
+
normalizeToLF,
|
|
24
|
+
splitLinesWithEndings,
|
|
25
|
+
type LineSpan,
|
|
26
|
+
} from "./text.ts";
|
|
27
|
+
|
|
28
|
+
export interface EditOp {
|
|
29
|
+
oldText: string;
|
|
30
|
+
newText: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 1-based inclusive line range. */
|
|
34
|
+
export interface LineRange {
|
|
35
|
+
start: number;
|
|
36
|
+
end: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type EditFailure =
|
|
40
|
+
| { kind: "empty-old-text"; editIndex: number }
|
|
41
|
+
| { kind: "not-found"; editIndex: number }
|
|
42
|
+
| {
|
|
43
|
+
kind: "ambiguous";
|
|
44
|
+
editIndex: number;
|
|
45
|
+
/** Offsets of every occurrence, enumerated in fully fuzzy-normalized space. */
|
|
46
|
+
occurrenceOffsets: number[];
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
kind: "overlap";
|
|
50
|
+
firstEditIndex: number;
|
|
51
|
+
secondEditIndex: number;
|
|
52
|
+
firstRange: LineRange;
|
|
53
|
+
secondRange: LineRange;
|
|
54
|
+
}
|
|
55
|
+
| { kind: "no-change" };
|
|
56
|
+
|
|
57
|
+
export interface Replacement {
|
|
58
|
+
editIndex: number;
|
|
59
|
+
matchIndex: number;
|
|
60
|
+
matchLength: number;
|
|
61
|
+
newText: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface EditAnalysis {
|
|
65
|
+
/** Sorted by matchIndex ascending. */
|
|
66
|
+
replacements: Replacement[];
|
|
67
|
+
usedFuzzyMatch: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FuzzyFindResult {
|
|
71
|
+
found: boolean;
|
|
72
|
+
index: number;
|
|
73
|
+
matchLength: number;
|
|
74
|
+
usedFuzzyMatch: boolean;
|
|
75
|
+
contentForReplacement: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type AnalyzeResult = { ok: true; analysis: EditAnalysis } | { ok: false; failure: EditFailure };
|
|
79
|
+
|
|
80
|
+
export function normalizeEdits(edits: EditOp[]): EditOp[] {
|
|
81
|
+
return edits.map((edit) => ({
|
|
82
|
+
oldText: normalizeToLF(edit.oldText),
|
|
83
|
+
newText: normalizeToLF(edit.newText),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Find oldText in content, trying exact match first, then fuzzy match. */
|
|
88
|
+
export function fuzzyFindText(content: string, oldText: string): FuzzyFindResult {
|
|
89
|
+
const exactIndex = content.indexOf(oldText);
|
|
90
|
+
if (exactIndex !== -1) {
|
|
91
|
+
return {
|
|
92
|
+
found: true,
|
|
93
|
+
index: exactIndex,
|
|
94
|
+
matchLength: oldText.length,
|
|
95
|
+
usedFuzzyMatch: false,
|
|
96
|
+
contentForReplacement: content,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const fuzzyContent = normalizeForFuzzyMatch(content);
|
|
100
|
+
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
|
101
|
+
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
|
|
102
|
+
if (fuzzyIndex === -1) {
|
|
103
|
+
return {
|
|
104
|
+
found: false,
|
|
105
|
+
index: -1,
|
|
106
|
+
matchLength: 0,
|
|
107
|
+
usedFuzzyMatch: false,
|
|
108
|
+
contentForReplacement: content,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
found: true,
|
|
113
|
+
index: fuzzyIndex,
|
|
114
|
+
matchLength: fuzzyOldText.length,
|
|
115
|
+
usedFuzzyMatch: true,
|
|
116
|
+
contentForReplacement: fuzzyContent,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function rangeOf(spans: LineSpan[], matchIndex: number, matchLength: number): LineRange {
|
|
121
|
+
return {
|
|
122
|
+
start: lineAt(spans, matchIndex) + 1,
|
|
123
|
+
end: lineAt(spans, matchIndex + Math.max(1, matchLength) - 1) + 1,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function analyzeEdits(normalizedContent: string, rawEdits: EditOp[]): AnalyzeResult {
|
|
128
|
+
const edits = normalizeEdits(rawEdits);
|
|
129
|
+
for (let i = 0; i < edits.length; i++) {
|
|
130
|
+
if (edits[i].oldText.length === 0) {
|
|
131
|
+
return { ok: false, failure: { kind: "empty-old-text", editIndex: i } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const initialMatches = edits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
|
|
136
|
+
const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
|
|
137
|
+
const base = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
|
|
138
|
+
const fuzzyBase = normalizeForFuzzyMatch(base);
|
|
139
|
+
const spans = getLineSpans(base);
|
|
140
|
+
|
|
141
|
+
const replacements: Replacement[] = [];
|
|
142
|
+
for (let i = 0; i < edits.length; i++) {
|
|
143
|
+
const edit = edits[i];
|
|
144
|
+
const matchResult = fuzzyFindText(base, edit.oldText);
|
|
145
|
+
if (!matchResult.found) {
|
|
146
|
+
return { ok: false, failure: { kind: "not-found", editIndex: i } };
|
|
147
|
+
}
|
|
148
|
+
const occurrences = countFuzzyOccurrences(fuzzyBase, edit.oldText);
|
|
149
|
+
if (occurrences > 1) {
|
|
150
|
+
const occurrenceOffsets = findAllOccurrences(fuzzyBase, normalizeForFuzzyMatch(edit.oldText));
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
failure: { kind: "ambiguous", editIndex: i, occurrenceOffsets },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
replacements.push({
|
|
157
|
+
editIndex: i,
|
|
158
|
+
matchIndex: matchResult.index,
|
|
159
|
+
matchLength: matchResult.matchLength,
|
|
160
|
+
newText: edit.newText,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
replacements.sort((a, b) => a.matchIndex - b.matchIndex);
|
|
165
|
+
for (let i = 1; i < replacements.length; i++) {
|
|
166
|
+
const previous = replacements[i - 1];
|
|
167
|
+
const current = replacements[i];
|
|
168
|
+
if (previous.matchIndex + previous.matchLength > current.matchIndex) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
failure: {
|
|
172
|
+
kind: "overlap",
|
|
173
|
+
firstEditIndex: previous.editIndex,
|
|
174
|
+
secondEditIndex: current.editIndex,
|
|
175
|
+
firstRange: rangeOf(spans, previous.matchIndex, previous.matchLength),
|
|
176
|
+
secondRange: rangeOf(spans, current.matchIndex, current.matchLength),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { ok: true, analysis: { replacements, usedFuzzyMatch } };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function applyReplacements(content: string, replacements: Replacement[], offset = 0): string {
|
|
186
|
+
let result = content;
|
|
187
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
188
|
+
const replacement = replacements[i];
|
|
189
|
+
const matchIndex = replacement.matchIndex - offset;
|
|
190
|
+
result =
|
|
191
|
+
result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 0-based, end-exclusive window of lines used by the fuzzy overlay. */
|
|
197
|
+
interface InternalLineWindow {
|
|
198
|
+
startLine: number;
|
|
199
|
+
endLine: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getReplacementLineRange(lines: LineSpan[], replacement: Replacement): InternalLineWindow {
|
|
203
|
+
const replacementStart = replacement.matchIndex;
|
|
204
|
+
const replacementEnd = replacement.matchIndex + replacement.matchLength;
|
|
205
|
+
let startLine = -1;
|
|
206
|
+
for (let i = 0; i < lines.length; i++) {
|
|
207
|
+
const line = lines[i];
|
|
208
|
+
if (replacementStart >= line.start && replacementStart < line.end) {
|
|
209
|
+
startLine = i;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (startLine === -1) {
|
|
214
|
+
throw new Error("Replacement range is outside the base content.");
|
|
215
|
+
}
|
|
216
|
+
let endLine = startLine;
|
|
217
|
+
while (endLine < lines.length && lines[endLine].end < replacementEnd) {
|
|
218
|
+
endLine++;
|
|
219
|
+
}
|
|
220
|
+
if (endLine >= lines.length) {
|
|
221
|
+
throw new Error("Replacement range is outside the base content.");
|
|
222
|
+
}
|
|
223
|
+
return { startLine, endLine: endLine + 1 };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Apply replacements matched against `baseContent` to `originalContent` while
|
|
228
|
+
* preserving unchanged line blocks from the original (fuzzy-match overlay).
|
|
229
|
+
*/
|
|
230
|
+
function applyReplacementsPreservingUnchangedLines(
|
|
231
|
+
originalContent: string,
|
|
232
|
+
baseContent: string,
|
|
233
|
+
replacements: Replacement[],
|
|
234
|
+
): string {
|
|
235
|
+
const originalLines = splitLinesWithEndings(originalContent);
|
|
236
|
+
const baseLines = getLineSpans(baseContent);
|
|
237
|
+
if (originalLines.length !== baseLines.length) {
|
|
238
|
+
throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const groups: Array<InternalLineWindow & { replacements: Replacement[] }> = [];
|
|
242
|
+
const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
|
|
243
|
+
for (const replacement of sortedReplacements) {
|
|
244
|
+
const range = getReplacementLineRange(baseLines, replacement);
|
|
245
|
+
const current = groups[groups.length - 1];
|
|
246
|
+
if (current && range.startLine < current.endLine) {
|
|
247
|
+
current.endLine = Math.max(current.endLine, range.endLine);
|
|
248
|
+
current.replacements.push(replacement);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
groups.push({ ...range, replacements: [replacement] });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let originalLineIndex = 0;
|
|
255
|
+
let result = "";
|
|
256
|
+
for (const group of groups) {
|
|
257
|
+
result += originalLines.slice(originalLineIndex, group.startLine).join("");
|
|
258
|
+
const groupStartOffset = baseLines[group.startLine].start;
|
|
259
|
+
const groupEndOffset = baseLines[group.endLine - 1].end;
|
|
260
|
+
result += applyReplacements(baseContent.slice(groupStartOffset, groupEndOffset), group.replacements, groupStartOffset);
|
|
261
|
+
originalLineIndex = group.endLine;
|
|
262
|
+
}
|
|
263
|
+
result += originalLines.slice(originalLineIndex).join("");
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function applyAnalysis(normalizedContent: string, analysis: EditAnalysis): { baseContent: string; newContent: string } {
|
|
268
|
+
const baseContent = normalizedContent;
|
|
269
|
+
const newContent = analysis.usedFuzzyMatch
|
|
270
|
+
? applyReplacementsPreservingUnchangedLines(
|
|
271
|
+
normalizedContent,
|
|
272
|
+
normalizeForFuzzyMatch(normalizedContent),
|
|
273
|
+
analysis.replacements,
|
|
274
|
+
)
|
|
275
|
+
: applyReplacements(baseContent, analysis.replacements);
|
|
276
|
+
return { baseContent, newContent };
|
|
277
|
+
}
|