apply-edit 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 +96 -0
- package/dist/index.cjs +481 -0
- package/dist/index.d.cts +115 -0
- package/dist/index.d.mts +115 -0
- package/dist/index.mjs +478 -0
- package/package.json +68 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
const BOM = '\uFEFF';
|
|
2
|
+
const PUNCTUATION = new Map([
|
|
3
|
+
['\u2010', '-'], ['\u2011', '-'], ['\u2012', '-'], ['\u2013', '-'], ['\u2014', '-'], ['\u2015', '-'], ['\u2212', '-'],
|
|
4
|
+
['\u2018', "'"], ['\u2019', "'"], ['\u201A', "'"], ['\u201B', "'"],
|
|
5
|
+
['\u201C', '"'], ['\u201D', '"'], ['\u201E', '"'], ['\u201F', '"'],
|
|
6
|
+
['\u00A0', ' '], ['\u2002', ' '], ['\u2003', ' '], ['\u2004', ' '], ['\u2005', ' '], ['\u2006', ' '], ['\u2007', ' '],
|
|
7
|
+
['\u2008', ' '], ['\u2009', ' '], ['\u200A', ' '], ['\u202F', ' '], ['\u205F', ' '], ['\u3000', ' '],
|
|
8
|
+
]);
|
|
9
|
+
const PUNCTUATION_PATTERN = /[\u2010-\u2015\u2212\u2018-\u201F\u00A0\u2002-\u200A\u202F\u205F\u3000]/g;
|
|
10
|
+
const ESCAPE_PATTERN = /\\(n|t|r|"|'|`|\\)/g;
|
|
11
|
+
const ESCAPES = {n: '\n', t: '\t', r: '\r', '"': '"', "'": "'", '`': '`', '\\': '\\'};
|
|
12
|
+
const ESCAPE_ALTERNATIVES = {
|
|
13
|
+
'\\n': '(?:\\n[ \\t]*|\\\\n)', '\\t': '(?:\\t|\\\\t)', '\\r': '(?:\\r|\\\\r)',
|
|
14
|
+
'\\"': '(?:"|\\\\")', "\\'": "(?:'|\\\\')", '\\`': '(?:`|\\\\`)', '\\\\': '(?:\\\\|\\\\\\\\)',
|
|
15
|
+
};
|
|
16
|
+
const TOLERANCE_TIERS = {exact: 0, whitespace: 4, unicode: 5};
|
|
17
|
+
|
|
18
|
+
const stripTrailing = line => line.replace(/[ \t\f\v]+$/, '');
|
|
19
|
+
const trimLine = line => line.trim();
|
|
20
|
+
const collapseWhitespace = line => line.replace(/\s+/g, ' ').trim();
|
|
21
|
+
const normalizeUnicode = line => collapseWhitespace(line.normalize('NFC').replace(PUNCTUATION_PATTERN, c => PUNCTUATION.get(c) ?? c));
|
|
22
|
+
const indentOf = line => /^[ \t]*/.exec(line)[0];
|
|
23
|
+
const isBlank = line => line.trim() === '';
|
|
24
|
+
const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
|
|
26
|
+
const TIERS = [
|
|
27
|
+
{name: 'exact'},
|
|
28
|
+
{name: 'trailing-whitespace', key: 'trailing', normalize: stripTrailing},
|
|
29
|
+
{name: 'indent-shift', key: 'indent-shift'},
|
|
30
|
+
{name: 'line-trimmed', key: 'trimmed', normalize: trimLine},
|
|
31
|
+
{name: 'whitespace-collapsed', key: 'collapsed', normalize: collapseWhitespace},
|
|
32
|
+
{name: 'unicode-normalized', key: 'unicode', normalize: normalizeUnicode},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function splitBom(text) {
|
|
36
|
+
return text.startsWith(BOM) ? {bom: BOM, body: text.slice(1)} : {bom: '', body: text};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Normalizes CRLF and lone CR to LF while recording where each normalized index came from.
|
|
40
|
+
function normalizeLineEndings(body) {
|
|
41
|
+
if (!body.includes('\r')) return {normalized: body, toRaw: null, crlf: 0, lf: countChar(body, '\n'), cr: 0};
|
|
42
|
+
let normalized = '';
|
|
43
|
+
let crlf = 0;
|
|
44
|
+
let lf = 0;
|
|
45
|
+
let cr = 0;
|
|
46
|
+
const toRaw = [];
|
|
47
|
+
let segmentStart = 0;
|
|
48
|
+
for (let i = 0; i < body.length; i++) {
|
|
49
|
+
if (body[i] !== '\r') {
|
|
50
|
+
if (body[i] === '\n') lf++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const segment = body.slice(segmentStart, i);
|
|
54
|
+
for (let j = 0; j < segment.length; j++) toRaw.push(segmentStart + j);
|
|
55
|
+
normalized += segment + '\n';
|
|
56
|
+
toRaw.push(i);
|
|
57
|
+
if (body[i + 1] === '\n') {
|
|
58
|
+
crlf++;
|
|
59
|
+
i++;
|
|
60
|
+
} else {
|
|
61
|
+
cr++;
|
|
62
|
+
}
|
|
63
|
+
segmentStart = i + 1;
|
|
64
|
+
}
|
|
65
|
+
const tail = body.slice(segmentStart);
|
|
66
|
+
for (let j = 0; j < tail.length; j++) toRaw.push(segmentStart + j);
|
|
67
|
+
normalized += tail;
|
|
68
|
+
toRaw.push(body.length);
|
|
69
|
+
return {normalized, toRaw, crlf, lf, cr};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function countChar(text, char) {
|
|
73
|
+
let count = 0;
|
|
74
|
+
for (let i = text.indexOf(char); i !== -1; i = text.indexOf(char, i + 1)) count++;
|
|
75
|
+
return count;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Line table built on first use; exact matches never need it.
|
|
79
|
+
function createLineTable(normalized) {
|
|
80
|
+
let built = null;
|
|
81
|
+
const build = () => {
|
|
82
|
+
if (built) return built;
|
|
83
|
+
const lines = normalized.split('\n');
|
|
84
|
+
const starts = new Array(lines.length);
|
|
85
|
+
let offset = 0;
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
starts[i] = offset;
|
|
88
|
+
offset += lines[i].length + 1;
|
|
89
|
+
}
|
|
90
|
+
built = {lines, starts, cache: new Map()};
|
|
91
|
+
return built;
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
get lines() { return build().lines; },
|
|
95
|
+
get starts() { return build().starts; },
|
|
96
|
+
normalized(key, fn) {
|
|
97
|
+
const table = build();
|
|
98
|
+
if (!table.cache.has(key)) table.cache.set(key, table.lines.map(fn));
|
|
99
|
+
return table.cache.get(key);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function exactSpans(text, needle) {
|
|
105
|
+
const spans = [];
|
|
106
|
+
for (let index = text.indexOf(needle); index !== -1; index = text.indexOf(needle, index + needle.length)) {
|
|
107
|
+
spans.push({start: index, end: index + needle.length});
|
|
108
|
+
}
|
|
109
|
+
return spans;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function addLineNumbers(normalized, spans) {
|
|
113
|
+
let line = 0;
|
|
114
|
+
let position = 0;
|
|
115
|
+
for (const span of spans) {
|
|
116
|
+
for (let i = normalized.indexOf('\n', position); i !== -1 && i < span.start; i = normalized.indexOf('\n', i + 1)) line++;
|
|
117
|
+
position = span.start;
|
|
118
|
+
span.startLine = line;
|
|
119
|
+
let endLine = line;
|
|
120
|
+
for (let i = normalized.indexOf('\n', span.start); i !== -1 && i < span.end - 1; i = normalized.indexOf('\n', i + 1)) endLine++;
|
|
121
|
+
span.endLine = endLine;
|
|
122
|
+
}
|
|
123
|
+
return spans;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function windowSpan(table, normalized, start, count, trailingNewline) {
|
|
127
|
+
const {lines, starts} = table;
|
|
128
|
+
const last = start + count - 1;
|
|
129
|
+
let end = starts[last] + lines[last].length;
|
|
130
|
+
if (trailingNewline && end < normalized.length && normalized[end] === '\n') end++;
|
|
131
|
+
return {start: starts[start], end, startLine: start, endLine: last};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Whole-line window matching with a per-line normalizer.
|
|
135
|
+
function lineSpans(table, normalized, oldLines, trailingNewline, key, normalize) {
|
|
136
|
+
const count = oldLines.length;
|
|
137
|
+
const lines = table.lines;
|
|
138
|
+
const spans = [];
|
|
139
|
+
if (count === 0 || count > lines.length) return spans;
|
|
140
|
+
const wanted = oldLines.map(normalize);
|
|
141
|
+
if (!wanted.some(Boolean)) return spans;
|
|
142
|
+
const source = table.normalized(key, normalize);
|
|
143
|
+
for (let i = 0; i + count <= lines.length; i++) {
|
|
144
|
+
if (source[i] !== wanted[0]) continue;
|
|
145
|
+
let matched = true;
|
|
146
|
+
for (let j = 1; j < count; j++) {
|
|
147
|
+
if (source[i + j] !== wanted[j]) { matched = false; break; }
|
|
148
|
+
}
|
|
149
|
+
if (matched) spans.push(windowSpan(table, normalized, i, count, trailingNewline));
|
|
150
|
+
}
|
|
151
|
+
return spans;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Lines match after trimming and every non-blank line shares one indentation delta, so relative structure is preserved.
|
|
155
|
+
function indentShiftSpans(table, normalized, oldLines, trailingNewline) {
|
|
156
|
+
const count = oldLines.length;
|
|
157
|
+
const lines = table.lines;
|
|
158
|
+
const spans = [];
|
|
159
|
+
if (count === 0 || count > lines.length) return spans;
|
|
160
|
+
const wantedText = oldLines.map(trimLine);
|
|
161
|
+
const wantedIndent = oldLines.map(indentOf);
|
|
162
|
+
const firstContent = wantedText.findIndex(Boolean);
|
|
163
|
+
if (firstContent === -1) return spans;
|
|
164
|
+
const text = table.normalized('trimmed', trimLine);
|
|
165
|
+
const indents = table.normalized('indent', indentOf);
|
|
166
|
+
for (let i = 0; i + count <= lines.length; i++) {
|
|
167
|
+
if (text[i + firstContent] !== wantedText[firstContent]) continue;
|
|
168
|
+
let matched = true;
|
|
169
|
+
let sign = null;
|
|
170
|
+
let delta = '';
|
|
171
|
+
for (let j = 0; j < count; j++) {
|
|
172
|
+
if (text[i + j] !== wantedText[j]) { matched = false; break; }
|
|
173
|
+
if (!wantedText[j]) continue;
|
|
174
|
+
const actual = indents[i + j];
|
|
175
|
+
const expected = wantedIndent[j];
|
|
176
|
+
if (sign === null) {
|
|
177
|
+
if (actual.startsWith(expected)) { sign = '+'; delta = actual.slice(expected.length); }
|
|
178
|
+
else if (expected.startsWith(actual)) { sign = '-'; delta = expected.slice(actual.length); }
|
|
179
|
+
else { matched = false; break; }
|
|
180
|
+
} else if (sign === '+' ? actual !== delta + expected : expected !== delta + actual) {
|
|
181
|
+
matched = false;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (matched) spans.push(windowSpan(table, normalized, i, count, trailingNewline));
|
|
186
|
+
}
|
|
187
|
+
return spans;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A literal escape sequence in the search text may stand for the real character or for the same literal in the file.
|
|
191
|
+
function escapeTolerantSpans(normalized, oldString) {
|
|
192
|
+
const parts = oldString.split(/(\\[ntr"'`\\])/);
|
|
193
|
+
if (parts.length < 3) return [];
|
|
194
|
+
const source = parts.map((part, i) => (i % 2 ? ESCAPE_ALTERNATIVES[part] : escapeRegExp(part))).join('');
|
|
195
|
+
const pattern = new RegExp(source, 'g');
|
|
196
|
+
const spans = [];
|
|
197
|
+
for (let match = pattern.exec(normalized); match; match = pattern.exec(normalized)) {
|
|
198
|
+
if (match[0].length === 0) { pattern.lastIndex++; continue; }
|
|
199
|
+
spans.push({start: match.index, end: match.index + match[0].length});
|
|
200
|
+
}
|
|
201
|
+
return spans;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function locate(normalized, table, oldString, options) {
|
|
205
|
+
const maxTier = TOLERANCE_TIERS[options.tolerance ?? 'unicode'];
|
|
206
|
+
if (maxTier === undefined) throw new TypeError(`Unknown tolerance: ${options.tolerance}`);
|
|
207
|
+
const attempts = [{prefix: '', text: oldString}];
|
|
208
|
+
const escaped = options.unescape !== false && ESCAPE_PATTERN.test(oldString);
|
|
209
|
+
ESCAPE_PATTERN.lastIndex = 0;
|
|
210
|
+
if (escaped) attempts.push({prefix: 'unescaped:', text: oldString.replace(ESCAPE_PATTERN, (m, c) => ESCAPES[c])});
|
|
211
|
+
for (const attempt of attempts) {
|
|
212
|
+
if (attempt.prefix) {
|
|
213
|
+
const spans = escapeTolerantSpans(normalized, oldString);
|
|
214
|
+
if (spans.length) return {strategy: 'escape-tolerant', spans: addLineNumbers(normalized, spans), oldLines: null};
|
|
215
|
+
}
|
|
216
|
+
const trailingNewline = attempt.text.endsWith('\n');
|
|
217
|
+
const oldLines = (trailingNewline ? attempt.text.slice(0, -1) : attempt.text).split('\n');
|
|
218
|
+
const whitespaceOnly = attempt.text.trim() === '';
|
|
219
|
+
for (let t = 0; t <= maxTier; t++) {
|
|
220
|
+
const tier = TIERS[t];
|
|
221
|
+
let spans;
|
|
222
|
+
if (tier.name === 'exact') spans = addLineNumbers(normalized, exactSpans(normalized, attempt.text));
|
|
223
|
+
else if (whitespaceOnly) break;
|
|
224
|
+
else if (tier.name === 'indent-shift') spans = indentShiftSpans(table, normalized, oldLines, trailingNewline);
|
|
225
|
+
else spans = lineSpans(table, normalized, oldLines, trailingNewline, tier.key, tier.normalize);
|
|
226
|
+
if (spans.length) return {strategy: attempt.prefix + tier.name, spans, oldLines: tier.name === 'exact' ? null : oldLines};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function bigrams(text) {
|
|
233
|
+
const map = new Map();
|
|
234
|
+
for (let i = 0; i + 1 < text.length; i++) {
|
|
235
|
+
const key = text.slice(i, i + 2);
|
|
236
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
237
|
+
}
|
|
238
|
+
return map;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function dice(a, b) {
|
|
242
|
+
if (a.length < 2 || b.length < 2) return a === b ? 1 : 0;
|
|
243
|
+
const left = bigrams(a);
|
|
244
|
+
const right = bigrams(b);
|
|
245
|
+
let shared = 0;
|
|
246
|
+
for (const [key, count] of left) shared += Math.min(count, right.get(key) ?? 0);
|
|
247
|
+
return (2 * shared) / (a.length - 1 + b.length - 1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Best window of the same height: most equal trimmed lines first, bigram similarity as the tie-breaker.
|
|
251
|
+
function nearestRegion(table, oldLines, limit) {
|
|
252
|
+
const lines = table.lines;
|
|
253
|
+
const count = oldLines.length;
|
|
254
|
+
if (count === 0 || lines.length === 0 || count > lines.length) return null;
|
|
255
|
+
const trimmed = table.normalized('trimmed', trimLine);
|
|
256
|
+
const wanted = oldLines.map(trimLine);
|
|
257
|
+
const content = wanted.map(Boolean);
|
|
258
|
+
const scanned = Math.min(lines.length, limit);
|
|
259
|
+
const scored = [];
|
|
260
|
+
for (let i = 0; i + count <= scanned; i++) {
|
|
261
|
+
let equal = 0;
|
|
262
|
+
for (let j = 0; j < count; j++) if (content[j] && trimmed[i + j] === wanted[j]) equal++;
|
|
263
|
+
if (equal > 0) scored.push([equal, i]);
|
|
264
|
+
}
|
|
265
|
+
let candidates;
|
|
266
|
+
if (scored.length) {
|
|
267
|
+
scored.sort((a, b) => b[0] - a[0]);
|
|
268
|
+
candidates = scored.slice(0, 25).map(entry => entry[1]);
|
|
269
|
+
} else if (count <= 3) {
|
|
270
|
+
candidates = Array.from({length: Math.max(0, Math.min(scanned, 20000) - count + 1)}, (_, i) => i);
|
|
271
|
+
} else {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const target = wanted.join('\n');
|
|
275
|
+
let best = null;
|
|
276
|
+
for (const i of candidates) {
|
|
277
|
+
const similarity = dice(trimmed.slice(i, i + count).join('\n'), target);
|
|
278
|
+
if (!best || similarity > best.similarity) best = {startLine: i, endLine: i + count - 1, similarity};
|
|
279
|
+
}
|
|
280
|
+
if (!best || best.similarity < 0.3) return null;
|
|
281
|
+
const differences = [];
|
|
282
|
+
for (let j = 0; j < count && differences.length < 5; j++) {
|
|
283
|
+
const actual = lines[best.startLine + j];
|
|
284
|
+
if (actual !== oldLines[j]) differences.push({line: best.startLine + j + 1, expected: oldLines[j], actual});
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
startLine: best.startLine + 1,
|
|
288
|
+
endLine: best.endLine + 1,
|
|
289
|
+
similarity: Math.round(best.similarity * 1000) / 1000,
|
|
290
|
+
preview: lines.slice(best.startLine, best.endLine + 1).map((line, j) => `${best.startLine + j + 1}| ${line}`).join('\n'),
|
|
291
|
+
differences,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function indentUnit(lines) {
|
|
296
|
+
let tabs = 0;
|
|
297
|
+
let spaces = 0;
|
|
298
|
+
let step = 0;
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
const indent = indentOf(line);
|
|
301
|
+
if (indent.startsWith('\t')) tabs++;
|
|
302
|
+
else if (indent.length) { spaces++; if (!step || indent.length < step) step = indent.length; }
|
|
303
|
+
}
|
|
304
|
+
return tabs > spaces ? {unit: '\t', width: 1} : {unit: ' ', width: step || 2};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function convertIndent(indent, from, to) {
|
|
308
|
+
if (!indent) return '';
|
|
309
|
+
const units = from.unit === '\t'
|
|
310
|
+
? countChar(indent, '\t') + Math.floor(countChar(indent, ' ') / from.width)
|
|
311
|
+
: Math.round(indent.replace(/\t/g, ' '.repeat(from.width)).length / from.width);
|
|
312
|
+
return to.unit.repeat(Math.max(0, units * to.width));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Maps every replacement line to the matched file line with the same trimmed content; new lines follow their anchor.
|
|
316
|
+
function reindent(replacement, oldLines, matchedLines, fileLines) {
|
|
317
|
+
const from = indentUnit(oldLines);
|
|
318
|
+
const to = indentUnit(fileLines);
|
|
319
|
+
const sameUnit = from.unit === to.unit;
|
|
320
|
+
let cursor = 0;
|
|
321
|
+
let anchor = 0;
|
|
322
|
+
let adjusted = false;
|
|
323
|
+
const lines = replacement.split('\n').map(line => {
|
|
324
|
+
if (isBlank(line)) return line;
|
|
325
|
+
const text = line.trim();
|
|
326
|
+
for (let j = cursor; j < oldLines.length; j++) {
|
|
327
|
+
if (oldLines[j].trim() !== text) continue;
|
|
328
|
+
cursor = j + 1;
|
|
329
|
+
anchor = j;
|
|
330
|
+
const indent = indentOf(matchedLines[j]);
|
|
331
|
+
if (indent !== indentOf(line)) adjusted = true;
|
|
332
|
+
return indent + text;
|
|
333
|
+
}
|
|
334
|
+
const oldAnchor = indentOf(oldLines[anchor] ?? '');
|
|
335
|
+
const fileAnchor = indentOf(matchedLines[anchor] ?? '');
|
|
336
|
+
const indent = indentOf(line);
|
|
337
|
+
let next;
|
|
338
|
+
if (indent.startsWith(oldAnchor)) {
|
|
339
|
+
const extra = indent.slice(oldAnchor.length);
|
|
340
|
+
next = fileAnchor + (sameUnit ? extra : convertIndent(extra, from, to));
|
|
341
|
+
} else if (oldAnchor.startsWith(indent)) {
|
|
342
|
+
const missing = oldAnchor.slice(indent.length);
|
|
343
|
+
const drop = sameUnit ? missing.length : convertIndent(missing, from, to).length;
|
|
344
|
+
next = fileAnchor.slice(0, Math.max(0, fileAnchor.length - drop));
|
|
345
|
+
} else {
|
|
346
|
+
next = sameUnit ? indent : convertIndent(indent, from, to);
|
|
347
|
+
}
|
|
348
|
+
if (next !== indent) adjusted = true;
|
|
349
|
+
return next + text;
|
|
350
|
+
});
|
|
351
|
+
return {text: lines.join('\n'), adjusted};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function candidateList(spans) {
|
|
355
|
+
return spans.map(span => ({startLine: span.startLine + 1, endLine: span.endLine + 1}));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function failure(code, message, extra) {
|
|
359
|
+
return {ok: false, code, message, ...extra};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function prepare(text) {
|
|
363
|
+
if (typeof text !== 'string') throw new TypeError('text must be a string');
|
|
364
|
+
const {bom, body} = splitBom(text);
|
|
365
|
+
const {normalized, toRaw, crlf, lf, cr} = normalizeLineEndings(body);
|
|
366
|
+
const lineEnding = crlf >= lf && crlf >= cr && crlf > 0 ? 'crlf' : cr > lf && cr > crlf ? 'cr' : 'lf';
|
|
367
|
+
const mixed = [crlf, lf, cr].filter(Boolean).length > 1;
|
|
368
|
+
return {text, bom, body, normalized, toRaw, lineEnding, eol: lineEnding === 'crlf' ? '\r\n' : lineEnding === 'cr' ? '\r' : '\n', mixed, table: createLineTable(normalized)};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function resolve(doc, edit, options) {
|
|
372
|
+
const {oldString, newString, replaceAll = false, occurrence, line} = edit;
|
|
373
|
+
if (typeof oldString !== 'string' || typeof newString !== 'string') throw new TypeError('oldString and newString must be strings');
|
|
374
|
+
const meta = {lineEnding: doc.lineEnding, bom: doc.bom !== ''};
|
|
375
|
+
if (oldString === '') return failure('empty-old', 'oldString must not be empty.', meta);
|
|
376
|
+
if (oldString === newString) return failure('no-change', 'oldString and newString are identical.', meta);
|
|
377
|
+
const normalizedOld = oldString.replace(/\r\n?/g, '\n');
|
|
378
|
+
const found = locate(doc.normalized, doc.table, normalizedOld, options);
|
|
379
|
+
if (!found) {
|
|
380
|
+
const closest = options.diagnostics === false ? null : nearestRegion(doc.table, normalizedOld.replace(/\n$/, '').split('\n'), 200000);
|
|
381
|
+
const hint = closest ? ` The closest region starts at line ${closest.startLine} (similarity ${closest.similarity}); re-read it and copy the exact text.` : '';
|
|
382
|
+
return failure('not-found', `oldString was not found in the text.${hint}`, {...meta, closest: closest ?? undefined});
|
|
383
|
+
}
|
|
384
|
+
let spans = found.spans;
|
|
385
|
+
const warnings = [];
|
|
386
|
+
if (spans.length > 1 && !replaceAll) {
|
|
387
|
+
if (occurrence !== undefined) {
|
|
388
|
+
if (!Number.isInteger(occurrence) || occurrence === 0) throw new RangeError('occurrence must be a non-zero integer');
|
|
389
|
+
const index = occurrence > 0 ? occurrence - 1 : spans.length + occurrence;
|
|
390
|
+
if (index < 0 || index >= spans.length) return failure('occurrence-out-of-range', `occurrence ${occurrence} is out of range: oldString matches ${spans.length} locations (strategy ${found.strategy}).`, {...meta, strategy: found.strategy, candidates: candidateList(spans)});
|
|
391
|
+
spans = [spans[index]];
|
|
392
|
+
} else if (line !== undefined) {
|
|
393
|
+
const distances = spans.map(span => Math.abs(span.startLine + 1 - line));
|
|
394
|
+
const nearest = Math.min(...distances);
|
|
395
|
+
const near = spans.filter((span, i) => distances[i] === nearest);
|
|
396
|
+
if (near.length !== 1 || nearest > 3) return failure('ambiguous', `oldString matches ${spans.length} locations (strategy ${found.strategy}) and the line hint ${line} does not select exactly one. Add surrounding context, pass occurrence, or set replaceAll.`, {...meta, strategy: found.strategy, candidates: candidateList(spans)});
|
|
397
|
+
spans = near;
|
|
398
|
+
} else {
|
|
399
|
+
return failure('ambiguous', `oldString matches ${spans.length} locations (strategy ${found.strategy}). Add surrounding context, pass occurrence, or set replaceAll.`, {...meta, strategy: found.strategy, candidates: candidateList(spans)});
|
|
400
|
+
}
|
|
401
|
+
} else if (spans.length === 1 && line !== undefined && Math.abs(spans[0].startLine + 1 - line) > 5) {
|
|
402
|
+
warnings.push(`The match starts at line ${spans[0].startLine + 1}, not near the line hint ${line}.`);
|
|
403
|
+
}
|
|
404
|
+
if (doc.mixed) warnings.push('The text mixes line-ending styles; replacements use the dominant style.');
|
|
405
|
+
const replacement = newString.replace(/\r\n?/g, '\n');
|
|
406
|
+
const raw = index => doc.bom.length + (doc.toRaw ? doc.toRaw[index] : index);
|
|
407
|
+
let reindented = false;
|
|
408
|
+
const pieces = spans.map(span => {
|
|
409
|
+
let text = replacement;
|
|
410
|
+
if (found.oldLines) {
|
|
411
|
+
const matchedLines = doc.table.lines.slice(span.startLine, span.endLine + 1);
|
|
412
|
+
const result = reindent(replacement, found.oldLines, matchedLines, doc.table.lines);
|
|
413
|
+
text = result.text;
|
|
414
|
+
reindented = reindented || result.adjusted;
|
|
415
|
+
}
|
|
416
|
+
if (doc.eol !== '\n') text = text.replace(/\n/g, doc.eol);
|
|
417
|
+
return {start: raw(span.start), end: raw(span.end), startLine: span.startLine + 1, endLine: span.endLine + 1, replacement: text};
|
|
418
|
+
});
|
|
419
|
+
return {ok: true, strategy: found.strategy, pieces, reindented, warnings, ...meta};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function splice(text, pieces) {
|
|
423
|
+
let output = '';
|
|
424
|
+
let previous = 0;
|
|
425
|
+
for (const piece of pieces) {
|
|
426
|
+
output += text.slice(previous, piece.start) + piece.replacement;
|
|
427
|
+
previous = piece.end;
|
|
428
|
+
}
|
|
429
|
+
return output + text.slice(previous);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function publicMatches(pieces) {
|
|
433
|
+
return pieces.map(({start, end, startLine, endLine}) => ({start, end, startLine, endLine}));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function findEdit(text, oldString, options = {}) {
|
|
437
|
+
const doc = prepare(text);
|
|
438
|
+
const result = resolve(doc, {oldString, newString: oldString + '\u0000', replaceAll: true}, options);
|
|
439
|
+
if (!result.ok) return result;
|
|
440
|
+
return {ok: true, strategy: result.strategy, matches: publicMatches(result.pieces), lineEnding: result.lineEnding, bom: result.bom};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function applyEdit(text, edit, options = {}) {
|
|
444
|
+
const doc = prepare(text);
|
|
445
|
+
const result = resolve(doc, edit, options);
|
|
446
|
+
if (!result.ok) return result;
|
|
447
|
+
return {
|
|
448
|
+
ok: true,
|
|
449
|
+
text: splice(text, result.pieces),
|
|
450
|
+
strategy: result.strategy,
|
|
451
|
+
replaced: result.pieces.length,
|
|
452
|
+
matches: publicMatches(result.pieces),
|
|
453
|
+
reindented: result.reindented,
|
|
454
|
+
warnings: result.warnings,
|
|
455
|
+
lineEnding: result.lineEnding,
|
|
456
|
+
bom: result.bom,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function applyEdits(text, edits, options = {}) {
|
|
461
|
+
if (!Array.isArray(edits) || edits.length === 0) throw new TypeError('edits must be a non-empty array');
|
|
462
|
+
const doc = prepare(text);
|
|
463
|
+
const applied = [];
|
|
464
|
+
const pieces = [];
|
|
465
|
+
for (const [index, edit] of edits.entries()) {
|
|
466
|
+
const result = resolve(doc, edit, options);
|
|
467
|
+
if (!result.ok) return {...result, index};
|
|
468
|
+
applied.push({index, strategy: result.strategy, matches: publicMatches(result.pieces), reindented: result.reindented, warnings: result.warnings});
|
|
469
|
+
for (const piece of result.pieces) pieces.push({...piece, index});
|
|
470
|
+
}
|
|
471
|
+
pieces.sort((a, b) => a.start - b.start || a.index - b.index);
|
|
472
|
+
for (let i = 1; i < pieces.length; i++) {
|
|
473
|
+
if (pieces[i].start < pieces[i - 1].end || (pieces[i].start === pieces[i - 1].start && pieces[i - 1].end === pieces[i - 1].start && pieces[i].end === pieces[i].start)) {
|
|
474
|
+
return failure('overlap', `Edits ${pieces[i - 1].index} and ${pieces[i].index} overlap (lines ${pieces[i - 1].startLine}-${pieces[i - 1].endLine} and ${pieces[i].startLine}-${pieces[i].endLine}).`, {index: pieces[i].index, lineEnding: doc.lineEnding, bom: doc.bom !== ''});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return {ok: true, text: splice(text, pieces), applied, lineEnding: doc.lineEnding, bom: doc.bom !== ''};
|
|
478
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "apply-edit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Apply LLM-generated oldString/newString edits safely: tolerant matching with ambiguity refusal, match reports, re-indentation and CRLF/BOM preservation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.mts",
|
|
13
|
+
"default": "./dist/index.mjs"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "Tom Ryan",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"llm",
|
|
34
|
+
"agent",
|
|
35
|
+
"coding-agent",
|
|
36
|
+
"edit",
|
|
37
|
+
"str_replace",
|
|
38
|
+
"search-replace",
|
|
39
|
+
"oldString",
|
|
40
|
+
"text-edit",
|
|
41
|
+
"tool",
|
|
42
|
+
"whitespace"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "node scripts/build.mjs",
|
|
46
|
+
"test": "node --test test/api.test.mjs test/issues.test.mjs",
|
|
47
|
+
"test:types": "tsc -p test/tsconfig.json",
|
|
48
|
+
"test:pack": "node scripts/test-pack.mjs",
|
|
49
|
+
"verify": "npm run build && npm test && npm run test:types && npm run test:pack",
|
|
50
|
+
"prepack": "npm run build",
|
|
51
|
+
"prepublishOnly": "npm run verify"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"typescript": "5.9.3"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public",
|
|
58
|
+
"registry": "https://registry.npmjs.org/"
|
|
59
|
+
},
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "git+https://github.com/Atomics-hub/apply-edit.git"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://github.com/Atomics-hub/apply-edit#readme",
|
|
65
|
+
"bugs": {
|
|
66
|
+
"url": "https://github.com/Atomics-hub/apply-edit/issues"
|
|
67
|
+
}
|
|
68
|
+
}
|