dsh-line-patch 0.2.0 → 0.3.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 +167 -117
- package/cordis.patch.yml +4 -4
- package/lib/core.js +475 -191
- package/lib/index.js +472 -411
- package/package.json +3 -2
package/lib/core.js
CHANGED
|
@@ -1,191 +1,475 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-line-patch — pure anchoring logic.
|
|
3
|
-
*
|
|
4
|
-
* No I/O and no host API lives here: every function takes text in and returns
|
|
5
|
-
* text out, so the whole editing contract is unit-testable without a Harness.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
* @param {string} content - the file text.
|
|
43
|
-
* @returns {string} '\
|
|
44
|
-
*/
|
|
45
|
-
export function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* @
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* @param {string}
|
|
119
|
-
* @
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-line-patch — pure anchoring logic.
|
|
3
|
+
*
|
|
4
|
+
* No I/O and no host API lives here: every function takes text in and returns
|
|
5
|
+
* text out, so the whole editing contract is unit-testable without a Harness.
|
|
6
|
+
*
|
|
7
|
+
* The anchors a model copies are *content* slices, not hashes: the first and
|
|
8
|
+
* last characters of the boundary lines are visible in a normal read, so
|
|
9
|
+
* nothing has to be recomputed to write them back.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
/** Characters kept from a boundary line — enough to identify it, cheap to copy. */
|
|
14
|
+
export const ANCHOR_CHARS = 16
|
|
15
|
+
|
|
16
|
+
/** Characters kept from the whole-file sha256 — enough to spot a changed file. */
|
|
17
|
+
export const FILE_HASH_CHARS = 6
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The hash shown underneath a read/write/edit result.
|
|
21
|
+
* @param {string|Buffer} content - raw file content.
|
|
22
|
+
* @returns {string} a 6-character sha256 prefix.
|
|
23
|
+
*/
|
|
24
|
+
export function fileHash(content) {
|
|
25
|
+
return createHash('sha256').update(content).digest('hex').slice(0, FILE_HASH_CHARS)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect the line ending a file is written in so replacements keep it.
|
|
30
|
+
* @param {string} content - the file text.
|
|
31
|
+
* @returns {string} '\r\n' or '\n'.
|
|
32
|
+
*/
|
|
33
|
+
export function detectEol(content) {
|
|
34
|
+
return content.includes('\r\n') ? '\r\n' : '\n'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Split text into lines while keeping each line's own ending.
|
|
39
|
+
*
|
|
40
|
+
* Rejoining the result must reproduce the input byte for byte; a final line
|
|
41
|
+
* without a newline therefore stays without one.
|
|
42
|
+
* @param {string} content - the file text.
|
|
43
|
+
* @returns {string[]} lines, each ending in '\n' except possibly the last.
|
|
44
|
+
*/
|
|
45
|
+
export function splitKeepEnds(content) {
|
|
46
|
+
const out = []
|
|
47
|
+
let start = 0
|
|
48
|
+
while (start < content.length) {
|
|
49
|
+
const nl = content.indexOf('\n', start)
|
|
50
|
+
if (nl < 0) {
|
|
51
|
+
out.push(content.slice(start))
|
|
52
|
+
break
|
|
53
|
+
}
|
|
54
|
+
out.push(content.slice(start, nl + 1))
|
|
55
|
+
start = nl + 1
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Strip a line's ending terms before comparing or showing it. */
|
|
61
|
+
function bareLine(line) {
|
|
62
|
+
return line.replace(/[\r\n]+$/, '')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reduce an anchor or a line to a canonical comparison form.
|
|
67
|
+
*
|
|
68
|
+
* Indentation drops away, runs of whitespace collapse to one space, and the
|
|
69
|
+
* ends are trimmed, so a model may copy an anchor with or without the layout
|
|
70
|
+
* whitespace around the code.
|
|
71
|
+
* @param {unknown} text - an anchor candidate or a real line.
|
|
72
|
+
* @returns {string} whitespace-collapsed text.
|
|
73
|
+
*/
|
|
74
|
+
export function normalizeAnchor(text) {
|
|
75
|
+
return String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Canonical form that keeps the line's own indentation.
|
|
80
|
+
*
|
|
81
|
+
* A model that copies the indentation it was shown gets a stricter comparison:
|
|
82
|
+
* `if (err) {` and ` if (err) {` are different lines, which is exactly the
|
|
83
|
+
* pair that a nesting mistake confuses. Inner whitespace still collapses.
|
|
84
|
+
* @param {unknown} text - an anchor candidate or a real line.
|
|
85
|
+
* @returns {string} indentation-preserving text.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeIndented(text) {
|
|
88
|
+
const raw = String(text ?? '')
|
|
89
|
+
const lead = raw.match(/^\s+/)?.[0] ?? ''
|
|
90
|
+
const rest = raw.slice(lead.length).replace(/\s+/g, ' ')
|
|
91
|
+
return `${lead}${rest}`.replace(/\s+$/, '')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Anchors that start with whitespace are compared with indentation kept. */
|
|
95
|
+
function keepsIndent(given) {
|
|
96
|
+
return /^\s/.test(String(given ?? ''))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The anchor a line offers at its start: its first `ANCHOR_CHARS` characters.
|
|
101
|
+
*
|
|
102
|
+
* Whitespace is collapsed and the leading indentation dropped, so this is the
|
|
103
|
+
* *shortest* anchor the contract accepts — a model that copies the read display
|
|
104
|
+
* including indentation sends a stricter (indented) one. Used to build anchors
|
|
105
|
+
* in tests and by callers that need the canonical short form; never for matching.
|
|
106
|
+
* @param {string} line - the line without its ending.
|
|
107
|
+
* @returns {string} the start anchor.
|
|
108
|
+
*/
|
|
109
|
+
export function startAnchorOf(line) {
|
|
110
|
+
return normalizeAnchor(line).slice(0, ANCHOR_CHARS)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The anchor a line offers at its end: its last `ANCHOR_CHARS` characters.
|
|
115
|
+
*
|
|
116
|
+
* Whitespace is collapsed the same way; the whitespace itself is not part of a
|
|
117
|
+
* comparable end anchor.
|
|
118
|
+
* @param {string} line - the line without its ending.
|
|
119
|
+
* @returns {string} the end anchor.
|
|
120
|
+
*/
|
|
121
|
+
export function endAnchorOf(line) {
|
|
122
|
+
return normalizeAnchor(line).slice(-ANCHOR_CHARS)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Does a model-supplied anchor identify this line's start?
|
|
127
|
+
*
|
|
128
|
+
* An empty anchor means "this line is blank". A start anchor is a slice of the
|
|
129
|
+
* line's first characters, so leading whitespace in it *is* the indentation:
|
|
130
|
+
* when the model carries it, the comparison keeps indentation and `if (err) {`
|
|
131
|
+
* stops matching ` if (err) {`. A copy without the indentation still matches
|
|
132
|
+
* the collapsed form — the length contract is enforced by `applyPatch`.
|
|
133
|
+
* @param {string} line - the line without its ending.
|
|
134
|
+
* @param {string} given - the anchor the model sent.
|
|
135
|
+
* @returns {boolean} true when the anchor identifies the line.
|
|
136
|
+
*/
|
|
137
|
+
export function matchesStart(line, given) {
|
|
138
|
+
const strict = keepsIndent(given)
|
|
139
|
+
const actual = strict ? normalizeIndented(line) : normalizeAnchor(line)
|
|
140
|
+
const want = strict ? normalizeIndented(given) : normalizeAnchor(given)
|
|
141
|
+
if (want.length === 0) return actual.length === 0
|
|
142
|
+
return actual.startsWith(want)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Does a model-supplied anchor identify this line's end?
|
|
147
|
+
*
|
|
148
|
+
* An end anchor is a slice of the line's *last* characters, so whitespace at
|
|
149
|
+
* its edge is content, not indentation; both sides are compared collapsed.
|
|
150
|
+
* @param {string} line - the line without its ending.
|
|
151
|
+
* @param {string} given - the anchor the model sent.
|
|
152
|
+
* @returns {boolean} true when the anchor identifies the line.
|
|
153
|
+
*/
|
|
154
|
+
export function matchesEnd(line, given) {
|
|
155
|
+
const actual = normalizeAnchor(line)
|
|
156
|
+
const want = normalizeAnchor(given)
|
|
157
|
+
if (want.length === 0) return actual.length === 0
|
|
158
|
+
return actual.endsWith(want)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Is the anchor as long as the contract requires?
|
|
163
|
+
*
|
|
164
|
+
* The model is told to copy a whole anchor (16 characters, or the whole line
|
|
165
|
+
* when it is shorter). A shorter copy still identifies a line by prefix, which
|
|
166
|
+
* would quietly turn a 16-character check into a two-character one.
|
|
167
|
+
* @param {string} line - the line the anchor should identify.
|
|
168
|
+
* @param {string} given - the anchor the model sent.
|
|
169
|
+
* @returns {boolean} true when the anchor is shorter than the contract allows.
|
|
170
|
+
*/
|
|
171
|
+
export function anchorTooShort(line, given) {
|
|
172
|
+
const raw = String(given ?? '')
|
|
173
|
+
if (raw.length >= ANCHOR_CHARS) return false
|
|
174
|
+
if (normalizeAnchor(given).length === 0) return false
|
|
175
|
+
return normalizeAnchor(given) !== normalizeAnchor(line)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Render an anchor for an error message, naming the blank case. */
|
|
179
|
+
function shown(anchor) {
|
|
180
|
+
return anchor.length === 0 ? '(blank)' : JSON.stringify(anchor)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Longest widening this plugin will suggest.
|
|
185
|
+
*
|
|
186
|
+
* The read renderer truncates displayed lines at 2,000 characters, so a longer
|
|
187
|
+
* anchor is text the model never saw and cannot usefully copy back.
|
|
188
|
+
*/
|
|
189
|
+
const SUGGESTION_CHARS = 2000
|
|
190
|
+
|
|
191
|
+
/** Every 1-based line number whose text matches the anchor on that boundary. */
|
|
192
|
+
function matchingLines(lines, given, boundary) {
|
|
193
|
+
const found = []
|
|
194
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
195
|
+
const line = bareLine(lines[index])
|
|
196
|
+
const matched = boundary === 'start' ? matchesStart(line, given) : matchesEnd(line, given)
|
|
197
|
+
if (matched) found.push(index + 1)
|
|
198
|
+
}
|
|
199
|
+
return found
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Render a short line-number list for an error message. */
|
|
203
|
+
function describeLines(numbers) {
|
|
204
|
+
const head = numbers.slice(0, 8).join(', ')
|
|
205
|
+
return numbers.length > 8 ? `lines ${head}, …` : `lines ${head}`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The shortest widening of `given` that matches exactly one line, or null.
|
|
210
|
+
*
|
|
211
|
+
* Only the lines that already matched the short anchor can ever match a longer
|
|
212
|
+
* one, so the search walks those (usually two or three) instead of the whole
|
|
213
|
+
* file — otherwise a pair of long generated lines would cost O(file × length).
|
|
214
|
+
* The search stops at `SUGGESTION_CHARS`: past the read display's own truncation
|
|
215
|
+
* the model has never seen the characters, so a longer suggestion cannot be
|
|
216
|
+
* copied back and the edit is left to the line number instead.
|
|
217
|
+
* @param {string[]} lines - the file's lines, endings included.
|
|
218
|
+
* @param {number[]} matches - 1-based lines the short anchor already matched.
|
|
219
|
+
* @param {string} full - the named line's normalized text.
|
|
220
|
+
* @param {string} given - the anchor the model sent.
|
|
221
|
+
* @param {'start'|'end'} boundary - which end of the line the anchor covers.
|
|
222
|
+
* @returns {string|null} a unique anchor, or null when none is worth suggesting.
|
|
223
|
+
*/
|
|
224
|
+
function uniqueWidening(lines, matches, full, given, boundary) {
|
|
225
|
+
const from = Math.max(normalizeAnchor(given).length, ANCHOR_CHARS) + 1
|
|
226
|
+
const limit = Math.min(full.length, SUGGESTION_CHARS)
|
|
227
|
+
for (let length = from; length <= limit; length += 1) {
|
|
228
|
+
const candidate = boundary === 'start' ? full.slice(0, length) : full.slice(-length)
|
|
229
|
+
let hits = 0
|
|
230
|
+
for (const number of matches) {
|
|
231
|
+
const line = bareLine(lines[number - 1])
|
|
232
|
+
const matched = boundary === 'start' ? matchesStart(line, candidate) : matchesEnd(line, candidate)
|
|
233
|
+
if (matched) hits += 1
|
|
234
|
+
if (hits > 1) break
|
|
235
|
+
}
|
|
236
|
+
if (hits === 1) return candidate
|
|
237
|
+
}
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Would this anchor silently point at more than one line, and can widening fix it?
|
|
243
|
+
*
|
|
244
|
+
* Two lines sharing their first (or last) 16 characters are indistinguishable
|
|
245
|
+
* from those two slices alone; if the line is longer, the model can copy more
|
|
246
|
+
* of it and be exact, so that is worth one corrective turn. When even the whole
|
|
247
|
+
* line repeats, no anchor can disambiguate it — the line number is the only
|
|
248
|
+
* signal, and rejecting would block edits that are in fact unambiguous.
|
|
249
|
+
* @param {string[]} lines - the file's lines, endings included.
|
|
250
|
+
* @param {number} lineNumber - the line the model named.
|
|
251
|
+
* @param {string} given - the anchor the model sent.
|
|
252
|
+
* @param {'start'|'end'} boundary - which end of the line the anchor covers.
|
|
253
|
+
* @returns {{matches: number[], suggestion: string}|null} the ambiguity, or null.
|
|
254
|
+
*/
|
|
255
|
+
function ambiguousAnchor(lines, lineNumber, given, boundary) {
|
|
256
|
+
const matches = matchingLines(lines, given, boundary)
|
|
257
|
+
if (matches.length <= 1) return null
|
|
258
|
+
const full = normalizeAnchor(bareLine(lines[lineNumber - 1]))
|
|
259
|
+
if (full.length <= normalizeAnchor(given).length) return null
|
|
260
|
+
const suggestion = uniqueWidening(lines, matches, full, given, boundary)
|
|
261
|
+
// No widening separates them (repeated whole line): the line number is the only
|
|
262
|
+
// signal, and rejecting would block edits that are in fact unambiguous.
|
|
263
|
+
if (suggestion === null) return null
|
|
264
|
+
return { matches, suggestion }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A non-empty anchor that carries no characters at all. */
|
|
268
|
+
function isWhitespaceOnly(anchor) {
|
|
269
|
+
const raw = String(anchor ?? '')
|
|
270
|
+
return raw.length > 0 && raw.trim().length === 0
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Normalise every line ending in a replacement to the file's own style. */
|
|
274
|
+
function normalizeEol(text, eol) {
|
|
275
|
+
const unified = text.replace(/\r\n|[\r\n]/g, '\n')
|
|
276
|
+
return eol === '\n' ? unified : unified.replace(/\n/g, eol)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Count the lines a replacement text occupies. */
|
|
280
|
+
function countLines(text) {
|
|
281
|
+
if (text.length === 0) return 0
|
|
282
|
+
const parts = text.split('\n')
|
|
283
|
+
if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop()
|
|
284
|
+
return parts.length
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Reject an argument the tool cannot act on, instead of throwing later. */
|
|
288
|
+
function badArgument(message) {
|
|
289
|
+
return { ok: false, code: 'BAD_ARGUMENT', message }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Apply an anchored line-range replacement.
|
|
294
|
+
*
|
|
295
|
+
* The whole-file hash is checked first: matching it proves the file is exactly
|
|
296
|
+
* the one the model read, so a later anchor mismatch cannot be a stale read —
|
|
297
|
+
* the model fixes the anchor or the line number without rereading.
|
|
298
|
+
* @param {object} input - the guarded edit request.
|
|
299
|
+
* @param {string} input.content - current file text.
|
|
300
|
+
* @param {string} input.expectedFileHash - `file_hash` the model was shown.
|
|
301
|
+
* @param {number} input.lineStart - 1-based first line to replace.
|
|
302
|
+
* @param {string} input.startAnchor - start anchor of that line.
|
|
303
|
+
* @param {number} input.lineEnd - 1-based last line to replace (inclusive).
|
|
304
|
+
* @param {string} input.endAnchor - end anchor of that line.
|
|
305
|
+
* @param {string} input.replace - replacement text; '' deletes the range.
|
|
306
|
+
* @returns {{ok: true, content: string, fileHash: string, lineShift: number, removedLines: number}
|
|
307
|
+
* | {ok: false, code: string, message: string}}
|
|
308
|
+
*/
|
|
309
|
+
export function applyPatch(input) {
|
|
310
|
+
const { content, expectedFileHash, lineStart, startAnchor, lineEnd, endAnchor, replace } = input
|
|
311
|
+
|
|
312
|
+
if (!Number.isInteger(lineStart) || !Number.isInteger(lineEnd)) {
|
|
313
|
+
return badArgument(
|
|
314
|
+
`line_start and line_end must be whole line numbers (got ${String(lineStart)} and ${String(lineEnd)}).`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
if (typeof replace !== 'string') {
|
|
318
|
+
return badArgument(`replace must be a string (got ${typeof replace}); pass "" to delete the range.`)
|
|
319
|
+
}
|
|
320
|
+
// A hash of the wrong shape is a different mistake from a stale file: saying
|
|
321
|
+
// "the file changed" would send the model to reread for nothing.
|
|
322
|
+
if (typeof expectedFileHash !== 'string' || !new RegExp(`^[0-9a-f]{${FILE_HASH_CHARS}}$`).test(expectedFileHash)) {
|
|
323
|
+
return badArgument(
|
|
324
|
+
`file_hash must be the ${FILE_HASH_CHARS}-character lowercase hex hash printed on the line under a read/write/edit result ` +
|
|
325
|
+
`(got ${JSON.stringify(expectedFileHash)}). If you were never shown such a line, this plugin's annotation is not active — use edit or write instead.`,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const actualFileHash = fileHash(content)
|
|
330
|
+
if (expectedFileHash !== actualFileHash) {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
code: 'FILE_HASH_MISMATCH',
|
|
334
|
+
message: `file_hash mismatch: expected ${expectedFileHash}, actual ${actualFileHash} — the file changed since it was read; read it again.`,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const lines = splitKeepEnds(content)
|
|
339
|
+
const total = lines.length
|
|
340
|
+
if (total === 0) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
code: 'RANGE_INVALID',
|
|
344
|
+
message: 'the file is empty, so there is no line to patch — use the write tool to add content instead.',
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (lineStart < 1 || lineStart > total) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
code: 'RANGE_INVALID',
|
|
351
|
+
message: `line_start ${lineStart} is out of range (file has ${total} lines). ` +
|
|
352
|
+
'The file itself is unchanged — fix the line number and retry; do not reread the file.',
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (lineEnd < lineStart || lineEnd > total) {
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
code: 'RANGE_INVALID',
|
|
359
|
+
message: `line_end ${lineEnd} is out of range (line_start ${lineStart}, file has ${total} lines). ` +
|
|
360
|
+
'The file itself is unchanged — fix the line number and retry; do not reread the file.',
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const startLine = bareLine(lines[lineStart - 1])
|
|
365
|
+
const startWant = normalizeAnchor(startAnchor)
|
|
366
|
+
if (isWhitespaceOnly(startAnchor)) {
|
|
367
|
+
const blankLine = normalizeAnchor(startLine).length === 0
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
code: 'ANCHOR_TOO_SHORT',
|
|
371
|
+
message: blankLine
|
|
372
|
+
? `start_anchor is only whitespace and line ${lineStart} is blank — pass "" to address a blank line. Do not reread the file.`
|
|
373
|
+
: `start_anchor is only whitespace — line ${lineStart} is indented past ${ANCHOR_CHARS} characters. ` +
|
|
374
|
+
`Copy ${ANCHOR_CHARS} characters starting at its first non-blank character (or the whole line). Do not reread the file.`,
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (anchorTooShort(startLine, startAnchor)) {
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
code: 'ANCHOR_TOO_SHORT',
|
|
381
|
+
message: `start_anchor ${shown(startWant)} is too short to identify a line — copy the first ${ANCHOR_CHARS} ` +
|
|
382
|
+
`characters of line ${lineStart}'s text (the part after the "N: " prefix in the read result; the whole line text when it is shorter). Do not reread the file.`,
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!matchesStart(startLine, startAnchor)) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
code: 'LINE_ANCHOR_MISMATCH',
|
|
389
|
+
message: `start_anchor ${shown(String(startAnchor ?? ''))} does not match line ${lineStart}, ` +
|
|
390
|
+
`which starts with ${shown(startLine.slice(0, ANCHOR_CHARS))}. ` +
|
|
391
|
+
'Copy it exactly, including the indentation; then fix start_anchor or line_start and retry — ' +
|
|
392
|
+
'do not reread the file (file_hash still matches).',
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const startAmbiguity = ambiguousAnchor(lines, lineStart, startAnchor, 'start')
|
|
396
|
+
if (startAmbiguity) {
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
code: 'ANCHOR_AMBIGUOUS',
|
|
400
|
+
message: `start_anchor ${shown(startWant)} matches ${describeLines(startAmbiguity.matches)} — ` +
|
|
401
|
+
`line ${lineStart} is longer than ${ANCHOR_CHARS} characters, so copy more of it, ` +
|
|
402
|
+
`for example ${JSON.stringify(startAmbiguity.suggestion)}. Do not reread the file (file_hash still matches).`,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const endLine = bareLine(lines[lineEnd - 1])
|
|
407
|
+
const endWant = normalizeAnchor(endAnchor)
|
|
408
|
+
if (isWhitespaceOnly(endAnchor)) {
|
|
409
|
+
const blankLine = normalizeAnchor(endLine).length === 0
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
code: 'ANCHOR_TOO_SHORT',
|
|
413
|
+
message: blankLine
|
|
414
|
+
? `end_anchor is only whitespace and line ${lineEnd} is blank — pass "" to address a blank line. Do not reread the file.`
|
|
415
|
+
: `end_anchor is only whitespace — line ${lineEnd} is padded with trailing whitespace. ` +
|
|
416
|
+
`Copy the last ${ANCHOR_CHARS} characters before that padding (or the whole line). Do not reread the file.`,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (anchorTooShort(endLine, endAnchor)) {
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
code: 'ANCHOR_TOO_SHORT',
|
|
423
|
+
message: `end_anchor ${shown(endWant)} is too short to identify a line — copy the last ${ANCHOR_CHARS} ` +
|
|
424
|
+
`characters of line ${lineEnd}'s text (the part after the "N: " prefix in the read result; the whole line text when it is shorter). Do not reread the file.`,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// A single-line range carries both anchors on one physical line, so a model
|
|
428
|
+
// that copied the head into both slots is right, not wrong.
|
|
429
|
+
const endOk = lineStart === lineEnd
|
|
430
|
+
? matchesEnd(endLine, endAnchor) || matchesStart(endLine, endAnchor)
|
|
431
|
+
: matchesEnd(endLine, endAnchor)
|
|
432
|
+
if (!endOk) {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
code: 'LINE_ANCHOR_MISMATCH',
|
|
436
|
+
message: `end_anchor ${shown(String(endAnchor ?? ''))} does not match line ${lineEnd}, ` +
|
|
437
|
+
`which ends with ${shown(endAnchorOf(endLine))}. ` +
|
|
438
|
+
'Fix end_anchor or line_end and retry; do not reread the file (file_hash still matches).',
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const endAmbiguity = ambiguousAnchor(lines, lineEnd, endAnchor, 'end')
|
|
442
|
+
if (endAmbiguity) {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
code: 'ANCHOR_AMBIGUOUS',
|
|
446
|
+
message: `end_anchor ${shown(endWant)} matches ${describeLines(endAmbiguity.matches)} — ` +
|
|
447
|
+
`line ${lineEnd} is longer than ${ANCHOR_CHARS} characters, so copy more of it, ` +
|
|
448
|
+
`for example ${JSON.stringify(endAmbiguity.suggestion)}. Do not reread the file (file_hash still matches).`,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const eol = detectEol(content)
|
|
453
|
+
const hadTrailingNewline = content.endsWith('\n')
|
|
454
|
+
let body = normalizeEol(replace, eol)
|
|
455
|
+
if (body.length > 0 && !body.endsWith('\n')) body += eol
|
|
456
|
+
|
|
457
|
+
const before = lines.slice(0, lineStart - 1)
|
|
458
|
+
const after = lines.slice(lineEnd)
|
|
459
|
+
if (after.length === 0 && !hadTrailingNewline) {
|
|
460
|
+
// Keep the "no trailing newline" shape, but never strip away the only line
|
|
461
|
+
// a `replace` of just a line terminator asked to insert.
|
|
462
|
+
const stripped = body.replace(/\r?\n$/, '')
|
|
463
|
+
if (stripped.length > 0 && countLines(stripped) === countLines(body)) body = stripped
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const next = before.join('') + body + after.join('')
|
|
467
|
+
const removedLines = lineEnd - lineStart + 1
|
|
468
|
+
return {
|
|
469
|
+
ok: true,
|
|
470
|
+
content: next,
|
|
471
|
+
fileHash: fileHash(next),
|
|
472
|
+
lineShift: countLines(body) - removedLines,
|
|
473
|
+
removedLines,
|
|
474
|
+
}
|
|
475
|
+
}
|