pi-hashline-edit-pro 6.4.7 → 6.5.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/README.md +9 -13
- package/package.json +9 -9
- package/src/anchor-registry.ts +33 -9
- package/src/batch.ts +2 -2
- package/src/copy-move.ts +8 -4
- package/src/edit-common.ts +12 -10
- package/src/edit-fidelity.ts +98 -41
- package/src/insert.ts +11 -4
- package/src/payload-contract.ts +41 -22
- package/src/read.ts +71 -35
- package/src/replace-diff.ts +38 -15
- package/src/replace-match.ts +67 -147
- package/src/replace.ts +6 -3
- package/src/utils.ts +22 -2
- package/tool-prompts/read-guidelines.md +1 -1
- package/tool-prompts/replace-match-guidelines.md +3 -3
- package/tool-prompts/replace-match-snippet.md +1 -1
- package/tool-prompts/replace-match.md +2 -2
package/src/replace-match.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import * as Diff from "diff";
|
|
4
3
|
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
|
|
5
4
|
import { commitEdit } from "./commit";
|
|
6
5
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
7
|
-
import { fmtRegion, MAX_HASH_LINES, parseHashRef,
|
|
6
|
+
import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
|
|
8
7
|
import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
9
8
|
import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
|
|
10
9
|
import { loadP, loadGuidelines } from "./prompts";
|
|
@@ -13,13 +12,13 @@ import {
|
|
|
13
12
|
buildReplaceMatchToolSchema,
|
|
14
13
|
getReplaceMatchInput,
|
|
15
14
|
normReq,
|
|
15
|
+
singleLineFragment,
|
|
16
16
|
type ReplaceMatchReq,
|
|
17
17
|
} from "./payload-contract";
|
|
18
18
|
import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
|
|
19
|
-
import {
|
|
20
|
-
import { isFidelitySensitiveChar } from "./edit-fidelity";
|
|
21
|
-
import { countNewlines, splitWithEndings } from "./line-endings";
|
|
19
|
+
import { type LineEnding } from "./normalize";
|
|
22
20
|
import { MAX_RANGE_STALE_LINES } from "./constants";
|
|
21
|
+
import { formatCharEscape, isFidelitySensitiveChar } from "./edit-fidelity";
|
|
23
22
|
import {
|
|
24
23
|
assertBoundaryLinesServed,
|
|
25
24
|
DEFAULT_EDIT_FLAGS,
|
|
@@ -43,73 +42,43 @@ interface MatchRefs {
|
|
|
43
42
|
|
|
44
43
|
export interface MatchPlan {
|
|
45
44
|
editParams: { remove_from: string; remove_to: string; text: string };
|
|
46
|
-
warnings: string[];
|
|
47
45
|
contentSeparators: (LineEnding | undefined)[];
|
|
48
46
|
servedOverride?: ReadonlyMap<string, string>;
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
function formatMatchRange(start: number, end: number): string {
|
|
52
|
-
return start === end ? `line ${start}` : `lines ${start}-${end}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
49
|
function replaceAllOccurrences(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
): { text: string;
|
|
50
|
+
line: string,
|
|
51
|
+
matchText: string,
|
|
52
|
+
replacementText: string,
|
|
53
|
+
): { text: string; count: number } {
|
|
60
54
|
const parts: string[] = [];
|
|
61
|
-
const inserted: Array<{ start: number; end: number }> = [];
|
|
62
|
-
let length = 0;
|
|
63
|
-
const append = (chunk: string, fromNewText: boolean): void => {
|
|
64
|
-
if (fromNewText) inserted.push({ start: length, end: length + chunk.length });
|
|
65
|
-
parts.push(chunk);
|
|
66
|
-
length += chunk.length;
|
|
67
|
-
};
|
|
68
55
|
let cursor = 0;
|
|
69
56
|
let count = 0;
|
|
70
57
|
for (;;) {
|
|
71
|
-
const found =
|
|
58
|
+
const found = line.indexOf(matchText, cursor);
|
|
72
59
|
if (found < 0) break;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
cursor = found + oldText.length;
|
|
60
|
+
parts.push(line.slice(cursor, found), replacementText);
|
|
61
|
+
cursor = found + matchText.length;
|
|
76
62
|
count += 1;
|
|
77
63
|
}
|
|
78
|
-
if (count === 0) return { text
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const parsed = splitWithEndings(replacement);
|
|
82
|
-
const separators: (LineEnding | undefined)[] = [];
|
|
83
|
-
let offset = 0;
|
|
84
|
-
let insertedIndex = 0;
|
|
85
|
-
for (let index = 0; index < parsed.lines.length; index++) {
|
|
86
|
-
offset += parsed.lines[index]!.length;
|
|
87
|
-
const ending = parsed.endings[index];
|
|
88
|
-
if (ending === undefined) continue;
|
|
89
|
-
while (insertedIndex < inserted.length && inserted[insertedIndex]!.end <= offset) insertedIndex += 1;
|
|
90
|
-
const fromNewText = insertedIndex < inserted.length && offset >= inserted[insertedIndex]!.start && offset < inserted[insertedIndex]!.end;
|
|
91
|
-
separators.push(fromNewText ? ending : undefined);
|
|
92
|
-
offset += ending.length;
|
|
93
|
-
}
|
|
94
|
-
return { text: replacement, separators, count };
|
|
64
|
+
if (count === 0) return { text: line, count: 0 };
|
|
65
|
+
parts.push(line.slice(cursor));
|
|
66
|
+
return { text: parts.join(""), count };
|
|
95
67
|
}
|
|
96
68
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const WHOLE_RANGE_MIN_CHARS = 40;
|
|
103
|
-
const WHOLE_RANGE_MIN_LINES = 2;
|
|
104
|
-
const NEAR_MISS_MIN_OLD_CHARS = 20;
|
|
105
|
-
const NEAR_MISS_MATCH_RATIO = 0.8;
|
|
69
|
+
function linesToPayloadText(lines: string[]): string {
|
|
70
|
+
if (lines.length === 0) return "";
|
|
71
|
+
const joined = lines.join("\n");
|
|
72
|
+
return lines[lines.length - 1] === "" ? `${joined}\n` : joined;
|
|
73
|
+
}
|
|
106
74
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
unchanged: number;
|
|
110
|
-
middle: { oldFragment: string; newFragment: string };
|
|
75
|
+
function fileInheritedEndings(lineCount: number): (LineEnding | undefined)[] {
|
|
76
|
+
return new Array<LineEnding | undefined>(lineCount).fill(undefined);
|
|
111
77
|
}
|
|
112
78
|
|
|
79
|
+
const NEAR_MISS_MIN_MATCH_CHARS = 20;
|
|
80
|
+
const NEAR_MISS_MATCH_RATIO = 0.8;
|
|
81
|
+
|
|
113
82
|
function commonPrefixLength(left: string, right: string): number {
|
|
114
83
|
const limit = Math.min(left.length, right.length);
|
|
115
84
|
let index = 0;
|
|
@@ -124,91 +93,50 @@ function commonSuffixLength(left: string, right: string, prefix: number): number
|
|
|
124
93
|
return suffix;
|
|
125
94
|
}
|
|
126
95
|
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const middle = {
|
|
131
|
-
oldFragment: oldText.slice(prefix, oldText.length - suffix),
|
|
132
|
-
newFragment: newText.slice(prefix, newText.length - suffix),
|
|
133
|
-
};
|
|
134
|
-
if (oldText.length + newText.length <= RETYPE_DIFF_MAX_CHARS) {
|
|
135
|
-
const parts = Diff.diffChars(oldText, newText, { maxEditLength: RETYPE_MAX_EDIT_LENGTH }) as Array<{ added?: boolean; removed?: boolean; value: string }> | undefined;
|
|
136
|
-
if (parts !== undefined) {
|
|
137
|
-
let changed = 0;
|
|
138
|
-
let removed = 0;
|
|
139
|
-
for (const part of parts) {
|
|
140
|
-
if (part.added === true) changed += part.value.length;
|
|
141
|
-
else if (part.removed === true) {
|
|
142
|
-
changed += part.value.length;
|
|
143
|
-
removed += part.value.length;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return { changed, unchanged: oldText.length - removed, middle };
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return { changed: middle.oldFragment.length + middle.newFragment.length, unchanged: prefix + suffix, middle };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function retypeSuggestion(analysis: RetypeAnalysis): string {
|
|
153
|
-
const { oldFragment, newFragment } = analysis.middle;
|
|
154
|
-
if (oldFragment.length === 0 && newFragment.length === 0) return "Send only the fragment that changes instead.";
|
|
155
|
-
if (oldFragment.length === 0) return "Send the added text with insert instead.";
|
|
156
|
-
if (
|
|
157
|
-
oldFragment.length > RETYPE_EXAMPLE_MAX_CHARS ||
|
|
158
|
-
newFragment.length > RETYPE_EXAMPLE_MAX_CHARS ||
|
|
159
|
-
oldFragment.includes("\n") ||
|
|
160
|
-
newFragment.includes("\n")
|
|
161
|
-
) {
|
|
162
|
-
return "Send only the fragment that changes instead.";
|
|
163
|
-
}
|
|
164
|
-
return `Send old_string ${JSON.stringify(oldFragment)} and new_string ${JSON.stringify(newFragment)} instead.`;
|
|
96
|
+
function charAt(text: string, index: number): string | undefined {
|
|
97
|
+
if (index >= text.length) return undefined;
|
|
98
|
+
return String.fromCodePoint(text.codePointAt(index)!);
|
|
165
99
|
}
|
|
166
100
|
|
|
167
|
-
function
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
const lineCount = oldText.split("\n").length;
|
|
173
|
-
return `[W_REPLACE_MATCH_RETYPE] "old_string" retypes ${analysis.unchanged} unchanged character(s) across ${lineCount} line(s). ${retypeSuggestion(analysis)}`;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function wholeRangeWarning(oldText: string, rangeText: string, lineCount: number, start: number, end: number, startHash: string, endHash: string): string | undefined {
|
|
177
|
-
if (oldText !== rangeText) return undefined;
|
|
178
|
-
if (lineCount < WHOLE_RANGE_MIN_LINES && oldText.length < WHOLE_RANGE_MIN_CHARS) return undefined;
|
|
179
|
-
return `[W_REPLACE_MATCH_WHOLE_RANGE] "old_string" is the entire selected range (${formatMatchRange(start, end)}); the anchors already identify it. Use replace with remove_from "${startHash}", remove_to "${endHash}" and the new text, or send only the fragment that changes.`;
|
|
101
|
+
function codePointStart(text: string, index: number): number {
|
|
102
|
+
if (index <= 0 || index >= text.length) return index;
|
|
103
|
+
const before = text.charCodeAt(index - 1);
|
|
104
|
+
const at = text.charCodeAt(index);
|
|
105
|
+
return before >= 0xd800 && before <= 0xdbff && at >= 0xdc00 && at <= 0xdfff ? index - 1 : index;
|
|
180
106
|
}
|
|
181
107
|
|
|
182
108
|
function describeChar(char: string | undefined): string {
|
|
183
109
|
if (char === undefined) return "the end of the text";
|
|
184
110
|
const codePoint = char.codePointAt(0)!;
|
|
185
|
-
|
|
186
|
-
return isFidelitySensitiveChar(char) || codePoint < 0x20 || codePoint > 0x7e
|
|
187
|
-
? `${shown} (U+${codePoint.toString(16).toUpperCase().padStart(4, "0")})`
|
|
188
|
-
: shown;
|
|
111
|
+
return isFidelitySensitiveChar(char) || codePoint > 0x7e ? `"${formatCharEscape(char)}"` : JSON.stringify(char);
|
|
189
112
|
}
|
|
190
113
|
|
|
191
|
-
function nearMissDetail(
|
|
192
|
-
if (
|
|
193
|
-
const prefix = commonPrefixLength(
|
|
194
|
-
const suffix = commonSuffixLength(
|
|
195
|
-
const comparable = Math.min(
|
|
114
|
+
function nearMissDetail(matchText: string, lineText: string, line: number): string | undefined {
|
|
115
|
+
if (matchText.length < NEAR_MISS_MIN_MATCH_CHARS) return undefined;
|
|
116
|
+
const prefix = commonPrefixLength(matchText, lineText);
|
|
117
|
+
const suffix = commonSuffixLength(matchText, lineText, prefix);
|
|
118
|
+
const comparable = Math.min(matchText.length, lineText.length);
|
|
196
119
|
if (prefix + suffix < Math.floor(comparable * NEAR_MISS_MATCH_RATIO)) return undefined;
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
120
|
+
const fileStart = codePointStart(lineText, prefix);
|
|
121
|
+
const matchStart = codePointStart(matchText, prefix);
|
|
122
|
+
const column = [...lineText.slice(0, fileStart)].length + 1;
|
|
123
|
+
return `the first mismatch is at line ${line}, col ${column}: the file has ${describeChar(charAt(lineText, fileStart))} where match has ${describeChar(charAt(matchText, matchStart))}.`;
|
|
200
124
|
}
|
|
201
125
|
|
|
202
|
-
function
|
|
126
|
+
function formatMatchRange(start: number, end: number): string {
|
|
127
|
+
return start === end ? `line ${start}` : `lines ${start}-${end}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function notFoundMessage(displayPath: string, start: number, end: number, fileHashes: string[], fileLines: string[], rangeText: string, matchText: string): string {
|
|
203
131
|
const rangeLength = end - start + 1;
|
|
204
132
|
const shownCount = Math.min(rangeLength, MAX_RANGE_STALE_LINES);
|
|
205
133
|
const shown = fmtRegion(fileHashes.slice(start - 1, start - 1 + shownCount), fileLines.slice(start - 1, start - 1 + shownCount));
|
|
206
|
-
const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}.]` : "";
|
|
207
|
-
const nearMiss = nearMissDetail(
|
|
134
|
+
const more = rangeLength > shownCount ? `\n[The range has ${rangeLength} lines; showing the first ${shownCount}. Call read with offset=${start + shownCount} to see the rest.]` : "";
|
|
135
|
+
const nearMiss = start === end ? nearMissDetail(matchText, rangeText, start) : undefined;
|
|
208
136
|
const advice = nearMiss !== undefined
|
|
209
|
-
? `${nearMiss} That looks like a retyped copy of the
|
|
210
|
-
: "Copy only the fragment that changes from the served row (
|
|
211
|
-
return `[E_SUBSTRING_NOT_FOUND] "
|
|
137
|
+
? `${nearMiss} That looks like a retyped copy of the line; send only the fragment that changes (copy it from the served row), or use replace with the same anchor.`
|
|
138
|
+
: "Copy only the fragment that changes from the served row (a trailing line break is accepted and dropped) and retry; for a whole-line rewrite use replace with the same anchors.";
|
|
139
|
+
return `[E_SUBSTRING_NOT_FOUND] "match" was not found in ${formatMatchRange(start, end)} of ${displayPath}. Current rows:\n\n${shown}${more}\n\n${advice}`;
|
|
212
140
|
}
|
|
213
141
|
|
|
214
142
|
export function parseMatchAnchors(req: ReplaceMatchReq): { refs: MatchRefs; warnings: string[] } {
|
|
@@ -225,6 +153,8 @@ export function buildReplaceMatchEdit(
|
|
|
225
153
|
displayPath: string,
|
|
226
154
|
served?: ReadonlyMap<string, string>,
|
|
227
155
|
): MatchPlan {
|
|
156
|
+
const matchText = singleLineFragment("match", req.match);
|
|
157
|
+
const replacementText = singleLineFragment("replacement", req.replacement);
|
|
228
158
|
const fileLines = splitLines(preload.normalized);
|
|
229
159
|
const fromLine = resolveAnchorLine(refs.from, fileLines, preload.fileHashes, displayPath);
|
|
230
160
|
const toLine = resolveAnchorLine(refs.to, fileLines, preload.fileHashes, displayPath);
|
|
@@ -232,33 +162,26 @@ export function buildReplaceMatchEdit(
|
|
|
232
162
|
const end = Math.max(fromLine, toLine);
|
|
233
163
|
assertBoundaryLinesServed(fileLines, preload.fileHashes, served, start, end, displayPath);
|
|
234
164
|
const rangeLines = fileLines.slice(start - 1, end);
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
165
|
+
let count = 0;
|
|
166
|
+
const replacementLines = rangeLines.map((line) => {
|
|
167
|
+
const result = replaceAllOccurrences(line, matchText, replacementText);
|
|
168
|
+
count += result.count;
|
|
169
|
+
return result.text;
|
|
170
|
+
});
|
|
239
171
|
if (count === 0) {
|
|
240
|
-
throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines,
|
|
172
|
+
throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, linesToPayloadText(rangeLines), matchText));
|
|
241
173
|
}
|
|
242
|
-
const
|
|
174
|
+
const replacement = linesToPayloadText(replacementLines);
|
|
243
175
|
const startRef = fromLine <= toLine ? refs.from : refs.to;
|
|
244
176
|
const endRef = fromLine <= toLine ? refs.to : refs.from;
|
|
245
177
|
const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
|
|
246
|
-
const warnings: string[] = [];
|
|
247
|
-
const retype = retypeWarning(oldText, newText);
|
|
248
|
-
if (retype !== undefined) {
|
|
249
|
-
warnings.push(retype);
|
|
250
|
-
} else {
|
|
251
|
-
const wholeRange = wholeRangeWarning(oldText, rangeText, rangeLines.length, start, end, startRef.hash, endRef.hash);
|
|
252
|
-
if (wholeRange !== undefined) warnings.push(wholeRange);
|
|
253
|
-
}
|
|
254
178
|
return {
|
|
255
179
|
editParams: {
|
|
256
180
|
remove_from: startRef.hash,
|
|
257
181
|
remove_to: endRef.hash,
|
|
258
182
|
text: replacement,
|
|
259
183
|
},
|
|
260
|
-
|
|
261
|
-
contentSeparators,
|
|
184
|
+
contentSeparators: fileInheritedEndings(replacementLines.length),
|
|
262
185
|
...(servedOverride !== undefined ? { servedOverride } : {}),
|
|
263
186
|
};
|
|
264
187
|
}
|
|
@@ -267,7 +190,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
|
|
|
267
190
|
const req = normReq(input.member.args, "replace");
|
|
268
191
|
assertReplaceMatchReq(req);
|
|
269
192
|
const { refs, warnings } = parseMatchAnchors(req);
|
|
270
|
-
const hints = [...literalEscapeHints([req.
|
|
193
|
+
const hints = [...literalEscapeHints([req.match], "match"), ...literalEscapeHints([req.replacement], "replacement")];
|
|
271
194
|
const preload = { normalized: input.base.content, fileHashes: input.base.hashes } as NormFile;
|
|
272
195
|
const matchPlan = buildReplaceMatchEdit(req, refs, preload, input.displayPath, input.served);
|
|
273
196
|
const resWarnings: string[] = [];
|
|
@@ -281,11 +204,10 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
|
|
|
281
204
|
});
|
|
282
205
|
return {
|
|
283
206
|
hedit,
|
|
284
|
-
warnings: [...warnings, ...hints, ...resWarnings
|
|
207
|
+
warnings: [...warnings, ...hints, ...resWarnings],
|
|
285
208
|
contentSeparators: matchPlan.contentSeparators,
|
|
286
209
|
planned,
|
|
287
210
|
foldedLines: 0,
|
|
288
|
-
...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
|
|
289
211
|
};
|
|
290
212
|
}
|
|
291
213
|
|
|
@@ -312,7 +234,6 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
|
|
|
312
234
|
signal,
|
|
313
235
|
});
|
|
314
236
|
const plan = buildReplaceMatchEdit(req, refs, preload, targetPath, servedForPath(preload.absolutePath));
|
|
315
|
-
await throwIfStrictInput(plan.warnings);
|
|
316
237
|
const pipe = await execPipeline(targetPath, plan.editParams, cwd, {
|
|
317
238
|
accessMode: constants.R_OK,
|
|
318
239
|
noPersist: true,
|
|
@@ -367,7 +288,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
|
|
|
367
288
|
if (member) noteBatchFailure(member, error);
|
|
368
289
|
throw error;
|
|
369
290
|
}
|
|
370
|
-
const hints = [...literalEscapeHints([req.
|
|
291
|
+
const hints = [...literalEscapeHints([req.match], "match"), ...literalEscapeHints([req.replacement], "replacement")];
|
|
371
292
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
372
293
|
removeFrom: req.replace_from,
|
|
373
294
|
removeTo: req.replace_to,
|
|
@@ -394,7 +315,6 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
|
|
|
394
315
|
await noteAnchorError(mutationTargetPath, error);
|
|
395
316
|
throw error;
|
|
396
317
|
}
|
|
397
|
-
await throwIfStrictInput(plan.warnings);
|
|
398
318
|
const pipe = await execPipeline(targetPath, plan.editParams, ctx.cwd, {
|
|
399
319
|
accessMode: constants.R_OK | constants.W_OK,
|
|
400
320
|
signal,
|
|
@@ -407,7 +327,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
|
|
|
407
327
|
absolutePath,
|
|
408
328
|
mutationTargetPath,
|
|
409
329
|
editAnchors: [plan.editParams.remove_from, plan.editParams.remove_to],
|
|
410
|
-
prefixWarnings: [...warnings, ...hints
|
|
330
|
+
prefixWarnings: [...warnings, ...hints],
|
|
411
331
|
signal,
|
|
412
332
|
verb: "replaced",
|
|
413
333
|
noopNoun: "Replacement",
|
package/src/replace.ts
CHANGED
|
@@ -87,6 +87,7 @@ export interface ExecPipelineOptions {
|
|
|
87
87
|
stripWarning?: StripWarningLocation;
|
|
88
88
|
endingOverrides?: (LineEnding | undefined)[];
|
|
89
89
|
preserveDeletionSeparators?: boolean;
|
|
90
|
+
anchorCarry?: number;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
|
|
@@ -205,20 +206,22 @@ export async function execPipeline(
|
|
|
205
206
|
const result = anchorResult.content;
|
|
206
207
|
const isNoop = result === originalNormalized;
|
|
207
208
|
|
|
209
|
+
const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
|
|
210
|
+
if (pipeSpan && options?.anchorCarry !== undefined) pipeSpan.carry = options.anchorCarry;
|
|
211
|
+
const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
|
|
212
|
+
|
|
208
213
|
const resultHashes = isNoop
|
|
209
214
|
? originalHashes
|
|
210
215
|
: await lineHashes(result, absolutePath, {
|
|
211
216
|
content: originalNormalized,
|
|
212
217
|
hashes: originalHashes,
|
|
218
|
+
...(pipeSpans ? { spans: pipeSpans } : {}),
|
|
213
219
|
}, hashStore, false, true);
|
|
214
220
|
const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
|
|
215
221
|
await throwIfStrictInput(warnings);
|
|
216
222
|
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|
|
217
223
|
effectiveEdit, originalHashes, isNoop,
|
|
218
224
|
);
|
|
219
|
-
|
|
220
|
-
const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
|
|
221
|
-
const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
|
|
222
225
|
return {
|
|
223
226
|
path: displayPath,
|
|
224
227
|
originalNormalized,
|
package/src/utils.ts
CHANGED
|
@@ -41,6 +41,24 @@ export function splitLines(text: string): string[] {
|
|
|
41
41
|
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export function spliceAll<T>(target: T[], start: number, deleteCount: number, items: readonly T[]): T[] {
|
|
45
|
+
const rawStart = Number.isNaN(start) ? 0 : Math.trunc(start);
|
|
46
|
+
const at = rawStart < 0 ? Math.max(target.length + rawStart, 0) : Math.min(rawStart, target.length);
|
|
47
|
+
const rawCount = Number.isNaN(deleteCount) ? 0 : Math.trunc(deleteCount);
|
|
48
|
+
const remove = Math.max(0, Math.min(rawCount, target.length - at));
|
|
49
|
+
const removed = target.slice(at, at + remove);
|
|
50
|
+
const tail = target.slice(at + remove);
|
|
51
|
+
const additions: readonly T[] = items === target ? [...items] : items;
|
|
52
|
+
target.length = at;
|
|
53
|
+
for (const item of additions) target.push(item);
|
|
54
|
+
for (const item of tail) target.push(item);
|
|
55
|
+
return removed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function anchorCarryIndex(direction: "after" | "before", lineCount: number): number {
|
|
59
|
+
return direction === "after" ? 0 : lineCount;
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
export function visLines(text: string): string[] {
|
|
45
63
|
return text.length === 0 ? [] : splitLines(text);
|
|
46
64
|
}
|
|
@@ -194,10 +212,12 @@ export function clipLine(line: string, maxLen = 200): string {
|
|
|
194
212
|
const flat = line.replace(/\n/g, "\\n");
|
|
195
213
|
return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
|
|
196
214
|
}
|
|
197
|
-
export function
|
|
198
|
-
const count = splitLines(content).length;
|
|
215
|
+
export function assertLineCount(count: number, displayPath: string, limit: number): void {
|
|
199
216
|
if (count > limit) throw new Error(formatLineLimit(displayPath, limit, count));
|
|
200
217
|
}
|
|
218
|
+
export function assertLineLimit(content: string, displayPath: string, limit: number): void {
|
|
219
|
+
assertLineCount(splitLines(content).length, displayPath, limit);
|
|
220
|
+
}
|
|
201
221
|
export function assertByteLimit(content: string, displayPath: string, limit = MAX_BYTES): void {
|
|
202
222
|
if (Buffer.byteLength(content, "utf-8") > limit) {
|
|
203
223
|
throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${displayPath} (exceeds the ${limit / (1024 * 1024)}MB size limit). For very large files, use write.`);
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+`/` ` rows and served `anchor│content` rows already carry fresh anchors for the changed range.
|
|
2
2
|
- `read`: an auto-read-all outline row shows a symbol label, or a single-line symbol's content; its `[limit N]` is the span to request.
|
|
3
|
-
- `read`: `replace_match` needs both boundary lines served; copy `
|
|
3
|
+
- `read`: `replace_match` needs both boundary lines served; copy `match` from a served row or read the lines first.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
- `replace_match`: `
|
|
2
|
-
- `replace_match`:
|
|
3
|
-
- `replace_match`: when the change covers
|
|
1
|
+
- `replace_match`: `match` is only the fragment that changes — never text that `replacement` leaves unchanged; every occurrence inside the range is replaced, so narrow the anchors when only some lines should change.
|
|
2
|
+
- `replace_match`: the anchors bound the range searched, and `match`/`replacement` must each stay within one line — a trailing line break is the line's terminator, an interior one belongs to `replace`, and a lone break cannot be a `match`.
|
|
3
|
+
- `replace_match`: when the change covers the whole line, use `replace` with the same anchor and the exact text.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace part of a line or range by anchor: every `
|
|
1
|
+
Replace part of a line or range by anchor: every `match` occurrence inside the range becomes `replacement`; the rest of each line stays untouched
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Replace part of a line
|
|
1
|
+
Replace part of a line — or the same fragment on several lines of a range — without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. Both stay within one line: a single trailing line break is accepted as the line's own terminator and dropped, while a line break anywhere else is refused; a lone line break leaves nothing to find in `match` and counts as `""` in `replacement`. Send only the fragment that changes — never text that `replacement` leaves unchanged. The anchors bound the search, so `match` only needs to be unique inside the selected range, not file-wide; when only one occurrence on a range should change, narrow `replace_from`/`replace_to` instead of lengthening the fragment. If `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line rewrite: use `replace` with the same anchors. `remove_from`/`remove_to` are accepted as aliases.
|
|
2
2
|
|
|
3
|
-
The two boundary anchors must still match what was last shown; lines strictly inside the range are
|
|
3
|
+
The two boundary anchors must still match what was last shown; lines strictly inside the range are searched as they are on disk.
|