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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Ryan
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,96 @@
1
+ # apply-edit
2
+
3
+ Apply the `oldString` → `newString` edits that language models produce, without guessing.
4
+
5
+ Coding agents ask a model for the exact text to replace. Models drift: wrong indentation, tabs for spaces, a trailing space, CRLF versus LF, typographic quotes, literal `\n` sequences. Exact matching then fails, and fuzzy matching quietly edits the wrong place. This package sits between the two: it tolerates the drift that can be resolved unambiguously, refuses everything else with a precise report, and never widens or relocates an edit.
6
+
7
+ Zero runtime dependencies. Strings in, strings out; no filesystem access. ESM, CommonJS and TypeScript declarations. Node 22+ and browsers.
8
+
9
+ ```sh
10
+ npm install apply-edit
11
+ ```
12
+
13
+ ```js
14
+ import {applyEdit} from 'apply-edit';
15
+
16
+ const file = 'def example():\n try:\n result = process_data()\n return result\n';
17
+ const result = applyEdit(file, {
18
+ oldString: 'result = process_data()\nreturn result', // the model dropped the indentation
19
+ newString: 'result = process_data()\nvalidate(result)\nreturn result',
20
+ });
21
+
22
+ result.ok; // true
23
+ result.strategy; // 'indent-shift' (not 'exact': tell the model, log it, or require exact in strict mode)
24
+ result.reindented; // true (newString was shifted to the file's indentation)
25
+ result.matches; // [{start: 27, end: 75, startLine: 3, endLine: 4}]
26
+ result.text; // the file with the block replaced at the right depth
27
+ ```
28
+
29
+ When the edit cannot be applied safely, the result says why in terms the model can act on:
30
+
31
+ ```js
32
+ applyEdit('foo\nbar\nfoo\n', {oldString: 'foo', newString: 'qux'});
33
+ // {ok: false, code: 'ambiguous', candidates: [{startLine: 1, endLine: 1}, {startLine: 3, endLine: 3}], ...}
34
+
35
+ applyEdit(file, {oldString: 'result = compute()\nreturn result', newString: '...'});
36
+ // {ok: false, code: 'not-found', closest: {startLine: 3, endLine: 4, similarity: 0.83, preview: '3| result = process_data()\n4| return result', differences: [...]}, ...}
37
+ ```
38
+
39
+ ## What it does
40
+
41
+ `applyEdit(text, edit, options?)` tries these strategies strictly in order and stops at the first that finds any candidate:
42
+
43
+ | strategy | tolerates | scope |
44
+ |---|---|---|
45
+ | `exact` | nothing | substrings anywhere |
46
+ | `trailing-whitespace` | trailing spaces and tabs per line | whole lines |
47
+ | `indent-shift` | one constant indentation offset across the block, relative structure kept | whole lines |
48
+ | `line-trimmed` | different indentation per line, tabs versus spaces | whole lines |
49
+ | `whitespace-collapsed` | runs of internal whitespace | whole lines |
50
+ | `unicode-normalized` | typographic quotes and dashes, non-breaking spaces, NFC | whole lines |
51
+ | `escape-tolerant` / `unescaped:<strategy>` | literal `\n`, `\t`, `\"` sequences that stand for the real characters | as above |
52
+
53
+ The rules that make it safe to use in a loop:
54
+
55
+ - A strategy with more than one candidate refuses with `ambiguous` and the candidate lines. The search never falls through to a looser strategy. Pass `replaceAll`, `occurrence` (1-based, negative from the end) or a `line` hint to select deliberately.
56
+ - Non-exact strategies match whole lines only, so a short fragment cannot fuzzily rewrite a larger region.
57
+ - There is no similarity threshold. Near misses are reported in `closest` (line-numbered preview and first differences), never applied.
58
+ - When a non-exact strategy located the block, `newString` is re-indented line by line to the matched region, including tab/space conversion. `reindented` says whether that happened.
59
+ - The text's byte order mark and dominant line-ending style are preserved; `oldString` and `newString` may use either style. Mixed-EOL input produces a warning.
60
+ - Every success reports `strategy`, `matches` (offsets and lines in the original text) and `warnings`, so a non-exact match can be surfaced to the model or rejected by strict callers.
61
+
62
+ `applyEdits(text, edits)` resolves every edit against the original text, refuses overlapping edits, and applies all of them or none. `findEdit(text, oldString)` locates without changing anything.
63
+
64
+ ## API
65
+
66
+ ### `applyEdit(text, edit, options?)`
67
+
68
+ `edit`: `{oldString, newString, replaceAll?, occurrence?, line?}`.
69
+
70
+ `options`: `{tolerance?: 'exact' | 'whitespace' | 'unicode', unescape?: boolean, diagnostics?: boolean}`. `tolerance` caps the loosest strategy (`'unicode'` is the default and includes everything above). `unescape: false` disables the escape-sequence strategies. `diagnostics: false` skips the closest-region search on `not-found`.
71
+
72
+ Returns `{ok: true, text, strategy, replaced, matches, reindented, warnings, lineEnding, bom}` or `{ok: false, code, message, ...}` with `code` one of `empty-old`, `no-change`, `not-found` (with `closest` when a plausible region exists), `ambiguous` (with `candidates` and `strategy`), `occurrence-out-of-range`. Invalid argument types throw `TypeError`; `occurrence: 0` throws `RangeError`.
73
+
74
+ ### `applyEdits(text, edits, options?)`
75
+
76
+ Returns `{ok: true, text, applied, lineEnding, bom}` or the first failure with its `index`. Overlapping edits fail with `code: 'overlap'`.
77
+
78
+ ### `findEdit(text, oldString, options?)`
79
+
80
+ Returns `{ok: true, strategy, matches, lineEnding, bom}` or a failure.
81
+
82
+ ## Limits
83
+
84
+ - Escaped-sequence recovery is heuristic when the code itself contains literal backslash sequences; ambiguous inputs are refused rather than guessed.
85
+ - Re-indentation maps replacement lines to matched lines by trimmed content and follows the previous anchor for new lines. Blocks that mix tabs and spaces within one line may keep the model's indentation; `reindented` and the diff tell you.
86
+ - Matching is line-oriented above the exact tier. Reordered or paraphrased lines are misses, reported through `closest`.
87
+ - This library does not read or write files, lock, or check that a file changed since it was read. Keep those in the tool.
88
+ - Unified diffs, SEARCH/REPLACE block parsing and model-assisted correction are out of scope.
89
+
90
+ ## Why not the usual fallback ladder
91
+
92
+ On a seeded corpus of 1,942 constructed edits over 12 real source files (drifted indentation, tabs, trailing whitespace, CRLF, typographic punctuation, escaped newlines, plus decoys, duplicates, stale blocks and non-contiguous input), this implementation applied 1,286 edits with the expected output and made 0 wrong-location or false applications. A transcription of a widely copied nine-replacer fallback ladder applied 116 edits wrongly (mostly stale blocks silently overwritten) and produced 302 outputs with the model's wrong indentation inserted verbatim; the closest published library made 144 to 333 wrong applications depending on its fuzz setting. Method and numbers are in [docs/comparison.md](docs/comparison.md). The corpus is synthetic and seeded, built from real files; it is not a sample of model output.
93
+
94
+ ## License
95
+
96
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,481 @@
1
+ 'use strict';
2
+ const BOM = '\uFEFF';
3
+ const PUNCTUATION = new Map([
4
+ ['\u2010', '-'], ['\u2011', '-'], ['\u2012', '-'], ['\u2013', '-'], ['\u2014', '-'], ['\u2015', '-'], ['\u2212', '-'],
5
+ ['\u2018', "'"], ['\u2019', "'"], ['\u201A', "'"], ['\u201B', "'"],
6
+ ['\u201C', '"'], ['\u201D', '"'], ['\u201E', '"'], ['\u201F', '"'],
7
+ ['\u00A0', ' '], ['\u2002', ' '], ['\u2003', ' '], ['\u2004', ' '], ['\u2005', ' '], ['\u2006', ' '], ['\u2007', ' '],
8
+ ['\u2008', ' '], ['\u2009', ' '], ['\u200A', ' '], ['\u202F', ' '], ['\u205F', ' '], ['\u3000', ' '],
9
+ ]);
10
+ const PUNCTUATION_PATTERN = /[\u2010-\u2015\u2212\u2018-\u201F\u00A0\u2002-\u200A\u202F\u205F\u3000]/g;
11
+ const ESCAPE_PATTERN = /\\(n|t|r|"|'|`|\\)/g;
12
+ const ESCAPES = {n: '\n', t: '\t', r: '\r', '"': '"', "'": "'", '`': '`', '\\': '\\'};
13
+ const ESCAPE_ALTERNATIVES = {
14
+ '\\n': '(?:\\n[ \\t]*|\\\\n)', '\\t': '(?:\\t|\\\\t)', '\\r': '(?:\\r|\\\\r)',
15
+ '\\"': '(?:"|\\\\")', "\\'": "(?:'|\\\\')", '\\`': '(?:`|\\\\`)', '\\\\': '(?:\\\\|\\\\\\\\)',
16
+ };
17
+ const TOLERANCE_TIERS = {exact: 0, whitespace: 4, unicode: 5};
18
+
19
+ const stripTrailing = line => line.replace(/[ \t\f\v]+$/, '');
20
+ const trimLine = line => line.trim();
21
+ const collapseWhitespace = line => line.replace(/\s+/g, ' ').trim();
22
+ const normalizeUnicode = line => collapseWhitespace(line.normalize('NFC').replace(PUNCTUATION_PATTERN, c => PUNCTUATION.get(c) ?? c));
23
+ const indentOf = line => /^[ \t]*/.exec(line)[0];
24
+ const isBlank = line => line.trim() === '';
25
+ const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
+
27
+ const TIERS = [
28
+ {name: 'exact'},
29
+ {name: 'trailing-whitespace', key: 'trailing', normalize: stripTrailing},
30
+ {name: 'indent-shift', key: 'indent-shift'},
31
+ {name: 'line-trimmed', key: 'trimmed', normalize: trimLine},
32
+ {name: 'whitespace-collapsed', key: 'collapsed', normalize: collapseWhitespace},
33
+ {name: 'unicode-normalized', key: 'unicode', normalize: normalizeUnicode},
34
+ ];
35
+
36
+ function splitBom(text) {
37
+ return text.startsWith(BOM) ? {bom: BOM, body: text.slice(1)} : {bom: '', body: text};
38
+ }
39
+
40
+ // Normalizes CRLF and lone CR to LF while recording where each normalized index came from.
41
+ function normalizeLineEndings(body) {
42
+ if (!body.includes('\r')) return {normalized: body, toRaw: null, crlf: 0, lf: countChar(body, '\n'), cr: 0};
43
+ let normalized = '';
44
+ let crlf = 0;
45
+ let lf = 0;
46
+ let cr = 0;
47
+ const toRaw = [];
48
+ let segmentStart = 0;
49
+ for (let i = 0; i < body.length; i++) {
50
+ if (body[i] !== '\r') {
51
+ if (body[i] === '\n') lf++;
52
+ continue;
53
+ }
54
+ const segment = body.slice(segmentStart, i);
55
+ for (let j = 0; j < segment.length; j++) toRaw.push(segmentStart + j);
56
+ normalized += segment + '\n';
57
+ toRaw.push(i);
58
+ if (body[i + 1] === '\n') {
59
+ crlf++;
60
+ i++;
61
+ } else {
62
+ cr++;
63
+ }
64
+ segmentStart = i + 1;
65
+ }
66
+ const tail = body.slice(segmentStart);
67
+ for (let j = 0; j < tail.length; j++) toRaw.push(segmentStart + j);
68
+ normalized += tail;
69
+ toRaw.push(body.length);
70
+ return {normalized, toRaw, crlf, lf, cr};
71
+ }
72
+
73
+ function countChar(text, char) {
74
+ let count = 0;
75
+ for (let i = text.indexOf(char); i !== -1; i = text.indexOf(char, i + 1)) count++;
76
+ return count;
77
+ }
78
+
79
+ // Line table built on first use; exact matches never need it.
80
+ function createLineTable(normalized) {
81
+ let built = null;
82
+ const build = () => {
83
+ if (built) return built;
84
+ const lines = normalized.split('\n');
85
+ const starts = new Array(lines.length);
86
+ let offset = 0;
87
+ for (let i = 0; i < lines.length; i++) {
88
+ starts[i] = offset;
89
+ offset += lines[i].length + 1;
90
+ }
91
+ built = {lines, starts, cache: new Map()};
92
+ return built;
93
+ };
94
+ return {
95
+ get lines() { return build().lines; },
96
+ get starts() { return build().starts; },
97
+ normalized(key, fn) {
98
+ const table = build();
99
+ if (!table.cache.has(key)) table.cache.set(key, table.lines.map(fn));
100
+ return table.cache.get(key);
101
+ },
102
+ };
103
+ }
104
+
105
+ function exactSpans(text, needle) {
106
+ const spans = [];
107
+ for (let index = text.indexOf(needle); index !== -1; index = text.indexOf(needle, index + needle.length)) {
108
+ spans.push({start: index, end: index + needle.length});
109
+ }
110
+ return spans;
111
+ }
112
+
113
+ function addLineNumbers(normalized, spans) {
114
+ let line = 0;
115
+ let position = 0;
116
+ for (const span of spans) {
117
+ for (let i = normalized.indexOf('\n', position); i !== -1 && i < span.start; i = normalized.indexOf('\n', i + 1)) line++;
118
+ position = span.start;
119
+ span.startLine = line;
120
+ let endLine = line;
121
+ for (let i = normalized.indexOf('\n', span.start); i !== -1 && i < span.end - 1; i = normalized.indexOf('\n', i + 1)) endLine++;
122
+ span.endLine = endLine;
123
+ }
124
+ return spans;
125
+ }
126
+
127
+ function windowSpan(table, normalized, start, count, trailingNewline) {
128
+ const {lines, starts} = table;
129
+ const last = start + count - 1;
130
+ let end = starts[last] + lines[last].length;
131
+ if (trailingNewline && end < normalized.length && normalized[end] === '\n') end++;
132
+ return {start: starts[start], end, startLine: start, endLine: last};
133
+ }
134
+
135
+ // Whole-line window matching with a per-line normalizer.
136
+ function lineSpans(table, normalized, oldLines, trailingNewline, key, normalize) {
137
+ const count = oldLines.length;
138
+ const lines = table.lines;
139
+ const spans = [];
140
+ if (count === 0 || count > lines.length) return spans;
141
+ const wanted = oldLines.map(normalize);
142
+ if (!wanted.some(Boolean)) return spans;
143
+ const source = table.normalized(key, normalize);
144
+ for (let i = 0; i + count <= lines.length; i++) {
145
+ if (source[i] !== wanted[0]) continue;
146
+ let matched = true;
147
+ for (let j = 1; j < count; j++) {
148
+ if (source[i + j] !== wanted[j]) { matched = false; break; }
149
+ }
150
+ if (matched) spans.push(windowSpan(table, normalized, i, count, trailingNewline));
151
+ }
152
+ return spans;
153
+ }
154
+
155
+ // Lines match after trimming and every non-blank line shares one indentation delta, so relative structure is preserved.
156
+ function indentShiftSpans(table, normalized, oldLines, trailingNewline) {
157
+ const count = oldLines.length;
158
+ const lines = table.lines;
159
+ const spans = [];
160
+ if (count === 0 || count > lines.length) return spans;
161
+ const wantedText = oldLines.map(trimLine);
162
+ const wantedIndent = oldLines.map(indentOf);
163
+ const firstContent = wantedText.findIndex(Boolean);
164
+ if (firstContent === -1) return spans;
165
+ const text = table.normalized('trimmed', trimLine);
166
+ const indents = table.normalized('indent', indentOf);
167
+ for (let i = 0; i + count <= lines.length; i++) {
168
+ if (text[i + firstContent] !== wantedText[firstContent]) continue;
169
+ let matched = true;
170
+ let sign = null;
171
+ let delta = '';
172
+ for (let j = 0; j < count; j++) {
173
+ if (text[i + j] !== wantedText[j]) { matched = false; break; }
174
+ if (!wantedText[j]) continue;
175
+ const actual = indents[i + j];
176
+ const expected = wantedIndent[j];
177
+ if (sign === null) {
178
+ if (actual.startsWith(expected)) { sign = '+'; delta = actual.slice(expected.length); }
179
+ else if (expected.startsWith(actual)) { sign = '-'; delta = expected.slice(actual.length); }
180
+ else { matched = false; break; }
181
+ } else if (sign === '+' ? actual !== delta + expected : expected !== delta + actual) {
182
+ matched = false;
183
+ break;
184
+ }
185
+ }
186
+ if (matched) spans.push(windowSpan(table, normalized, i, count, trailingNewline));
187
+ }
188
+ return spans;
189
+ }
190
+
191
+ // A literal escape sequence in the search text may stand for the real character or for the same literal in the file.
192
+ function escapeTolerantSpans(normalized, oldString) {
193
+ const parts = oldString.split(/(\\[ntr"'`\\])/);
194
+ if (parts.length < 3) return [];
195
+ const source = parts.map((part, i) => (i % 2 ? ESCAPE_ALTERNATIVES[part] : escapeRegExp(part))).join('');
196
+ const pattern = new RegExp(source, 'g');
197
+ const spans = [];
198
+ for (let match = pattern.exec(normalized); match; match = pattern.exec(normalized)) {
199
+ if (match[0].length === 0) { pattern.lastIndex++; continue; }
200
+ spans.push({start: match.index, end: match.index + match[0].length});
201
+ }
202
+ return spans;
203
+ }
204
+
205
+ function locate(normalized, table, oldString, options) {
206
+ const maxTier = TOLERANCE_TIERS[options.tolerance ?? 'unicode'];
207
+ if (maxTier === undefined) throw new TypeError(`Unknown tolerance: ${options.tolerance}`);
208
+ const attempts = [{prefix: '', text: oldString}];
209
+ const escaped = options.unescape !== false && ESCAPE_PATTERN.test(oldString);
210
+ ESCAPE_PATTERN.lastIndex = 0;
211
+ if (escaped) attempts.push({prefix: 'unescaped:', text: oldString.replace(ESCAPE_PATTERN, (m, c) => ESCAPES[c])});
212
+ for (const attempt of attempts) {
213
+ if (attempt.prefix) {
214
+ const spans = escapeTolerantSpans(normalized, oldString);
215
+ if (spans.length) return {strategy: 'escape-tolerant', spans: addLineNumbers(normalized, spans), oldLines: null};
216
+ }
217
+ const trailingNewline = attempt.text.endsWith('\n');
218
+ const oldLines = (trailingNewline ? attempt.text.slice(0, -1) : attempt.text).split('\n');
219
+ const whitespaceOnly = attempt.text.trim() === '';
220
+ for (let t = 0; t <= maxTier; t++) {
221
+ const tier = TIERS[t];
222
+ let spans;
223
+ if (tier.name === 'exact') spans = addLineNumbers(normalized, exactSpans(normalized, attempt.text));
224
+ else if (whitespaceOnly) break;
225
+ else if (tier.name === 'indent-shift') spans = indentShiftSpans(table, normalized, oldLines, trailingNewline);
226
+ else spans = lineSpans(table, normalized, oldLines, trailingNewline, tier.key, tier.normalize);
227
+ if (spans.length) return {strategy: attempt.prefix + tier.name, spans, oldLines: tier.name === 'exact' ? null : oldLines};
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+
233
+ function bigrams(text) {
234
+ const map = new Map();
235
+ for (let i = 0; i + 1 < text.length; i++) {
236
+ const key = text.slice(i, i + 2);
237
+ map.set(key, (map.get(key) ?? 0) + 1);
238
+ }
239
+ return map;
240
+ }
241
+
242
+ function dice(a, b) {
243
+ if (a.length < 2 || b.length < 2) return a === b ? 1 : 0;
244
+ const left = bigrams(a);
245
+ const right = bigrams(b);
246
+ let shared = 0;
247
+ for (const [key, count] of left) shared += Math.min(count, right.get(key) ?? 0);
248
+ return (2 * shared) / (a.length - 1 + b.length - 1);
249
+ }
250
+
251
+ // Best window of the same height: most equal trimmed lines first, bigram similarity as the tie-breaker.
252
+ function nearestRegion(table, oldLines, limit) {
253
+ const lines = table.lines;
254
+ const count = oldLines.length;
255
+ if (count === 0 || lines.length === 0 || count > lines.length) return null;
256
+ const trimmed = table.normalized('trimmed', trimLine);
257
+ const wanted = oldLines.map(trimLine);
258
+ const content = wanted.map(Boolean);
259
+ const scanned = Math.min(lines.length, limit);
260
+ const scored = [];
261
+ for (let i = 0; i + count <= scanned; i++) {
262
+ let equal = 0;
263
+ for (let j = 0; j < count; j++) if (content[j] && trimmed[i + j] === wanted[j]) equal++;
264
+ if (equal > 0) scored.push([equal, i]);
265
+ }
266
+ let candidates;
267
+ if (scored.length) {
268
+ scored.sort((a, b) => b[0] - a[0]);
269
+ candidates = scored.slice(0, 25).map(entry => entry[1]);
270
+ } else if (count <= 3) {
271
+ candidates = Array.from({length: Math.max(0, Math.min(scanned, 20000) - count + 1)}, (_, i) => i);
272
+ } else {
273
+ return null;
274
+ }
275
+ const target = wanted.join('\n');
276
+ let best = null;
277
+ for (const i of candidates) {
278
+ const similarity = dice(trimmed.slice(i, i + count).join('\n'), target);
279
+ if (!best || similarity > best.similarity) best = {startLine: i, endLine: i + count - 1, similarity};
280
+ }
281
+ if (!best || best.similarity < 0.3) return null;
282
+ const differences = [];
283
+ for (let j = 0; j < count && differences.length < 5; j++) {
284
+ const actual = lines[best.startLine + j];
285
+ if (actual !== oldLines[j]) differences.push({line: best.startLine + j + 1, expected: oldLines[j], actual});
286
+ }
287
+ return {
288
+ startLine: best.startLine + 1,
289
+ endLine: best.endLine + 1,
290
+ similarity: Math.round(best.similarity * 1000) / 1000,
291
+ preview: lines.slice(best.startLine, best.endLine + 1).map((line, j) => `${best.startLine + j + 1}| ${line}`).join('\n'),
292
+ differences,
293
+ };
294
+ }
295
+
296
+ function indentUnit(lines) {
297
+ let tabs = 0;
298
+ let spaces = 0;
299
+ let step = 0;
300
+ for (const line of lines) {
301
+ const indent = indentOf(line);
302
+ if (indent.startsWith('\t')) tabs++;
303
+ else if (indent.length) { spaces++; if (!step || indent.length < step) step = indent.length; }
304
+ }
305
+ return tabs > spaces ? {unit: '\t', width: 1} : {unit: ' ', width: step || 2};
306
+ }
307
+
308
+ function convertIndent(indent, from, to) {
309
+ if (!indent) return '';
310
+ const units = from.unit === '\t'
311
+ ? countChar(indent, '\t') + Math.floor(countChar(indent, ' ') / from.width)
312
+ : Math.round(indent.replace(/\t/g, ' '.repeat(from.width)).length / from.width);
313
+ return to.unit.repeat(Math.max(0, units * to.width));
314
+ }
315
+
316
+ // Maps every replacement line to the matched file line with the same trimmed content; new lines follow their anchor.
317
+ function reindent(replacement, oldLines, matchedLines, fileLines) {
318
+ const from = indentUnit(oldLines);
319
+ const to = indentUnit(fileLines);
320
+ const sameUnit = from.unit === to.unit;
321
+ let cursor = 0;
322
+ let anchor = 0;
323
+ let adjusted = false;
324
+ const lines = replacement.split('\n').map(line => {
325
+ if (isBlank(line)) return line;
326
+ const text = line.trim();
327
+ for (let j = cursor; j < oldLines.length; j++) {
328
+ if (oldLines[j].trim() !== text) continue;
329
+ cursor = j + 1;
330
+ anchor = j;
331
+ const indent = indentOf(matchedLines[j]);
332
+ if (indent !== indentOf(line)) adjusted = true;
333
+ return indent + text;
334
+ }
335
+ const oldAnchor = indentOf(oldLines[anchor] ?? '');
336
+ const fileAnchor = indentOf(matchedLines[anchor] ?? '');
337
+ const indent = indentOf(line);
338
+ let next;
339
+ if (indent.startsWith(oldAnchor)) {
340
+ const extra = indent.slice(oldAnchor.length);
341
+ next = fileAnchor + (sameUnit ? extra : convertIndent(extra, from, to));
342
+ } else if (oldAnchor.startsWith(indent)) {
343
+ const missing = oldAnchor.slice(indent.length);
344
+ const drop = sameUnit ? missing.length : convertIndent(missing, from, to).length;
345
+ next = fileAnchor.slice(0, Math.max(0, fileAnchor.length - drop));
346
+ } else {
347
+ next = sameUnit ? indent : convertIndent(indent, from, to);
348
+ }
349
+ if (next !== indent) adjusted = true;
350
+ return next + text;
351
+ });
352
+ return {text: lines.join('\n'), adjusted};
353
+ }
354
+
355
+ function candidateList(spans) {
356
+ return spans.map(span => ({startLine: span.startLine + 1, endLine: span.endLine + 1}));
357
+ }
358
+
359
+ function failure(code, message, extra) {
360
+ return {ok: false, code, message, ...extra};
361
+ }
362
+
363
+ function prepare(text) {
364
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
365
+ const {bom, body} = splitBom(text);
366
+ const {normalized, toRaw, crlf, lf, cr} = normalizeLineEndings(body);
367
+ const lineEnding = crlf >= lf && crlf >= cr && crlf > 0 ? 'crlf' : cr > lf && cr > crlf ? 'cr' : 'lf';
368
+ const mixed = [crlf, lf, cr].filter(Boolean).length > 1;
369
+ return {text, bom, body, normalized, toRaw, lineEnding, eol: lineEnding === 'crlf' ? '\r\n' : lineEnding === 'cr' ? '\r' : '\n', mixed, table: createLineTable(normalized)};
370
+ }
371
+
372
+ function resolve(doc, edit, options) {
373
+ const {oldString, newString, replaceAll = false, occurrence, line} = edit;
374
+ if (typeof oldString !== 'string' || typeof newString !== 'string') throw new TypeError('oldString and newString must be strings');
375
+ const meta = {lineEnding: doc.lineEnding, bom: doc.bom !== ''};
376
+ if (oldString === '') return failure('empty-old', 'oldString must not be empty.', meta);
377
+ if (oldString === newString) return failure('no-change', 'oldString and newString are identical.', meta);
378
+ const normalizedOld = oldString.replace(/\r\n?/g, '\n');
379
+ const found = locate(doc.normalized, doc.table, normalizedOld, options);
380
+ if (!found) {
381
+ const closest = options.diagnostics === false ? null : nearestRegion(doc.table, normalizedOld.replace(/\n$/, '').split('\n'), 200000);
382
+ const hint = closest ? ` The closest region starts at line ${closest.startLine} (similarity ${closest.similarity}); re-read it and copy the exact text.` : '';
383
+ return failure('not-found', `oldString was not found in the text.${hint}`, {...meta, closest: closest ?? undefined});
384
+ }
385
+ let spans = found.spans;
386
+ const warnings = [];
387
+ if (spans.length > 1 && !replaceAll) {
388
+ if (occurrence !== undefined) {
389
+ if (!Number.isInteger(occurrence) || occurrence === 0) throw new RangeError('occurrence must be a non-zero integer');
390
+ const index = occurrence > 0 ? occurrence - 1 : spans.length + occurrence;
391
+ 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)});
392
+ spans = [spans[index]];
393
+ } else if (line !== undefined) {
394
+ const distances = spans.map(span => Math.abs(span.startLine + 1 - line));
395
+ const nearest = Math.min(...distances);
396
+ const near = spans.filter((span, i) => distances[i] === nearest);
397
+ 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)});
398
+ spans = near;
399
+ } else {
400
+ 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)});
401
+ }
402
+ } else if (spans.length === 1 && line !== undefined && Math.abs(spans[0].startLine + 1 - line) > 5) {
403
+ warnings.push(`The match starts at line ${spans[0].startLine + 1}, not near the line hint ${line}.`);
404
+ }
405
+ if (doc.mixed) warnings.push('The text mixes line-ending styles; replacements use the dominant style.');
406
+ const replacement = newString.replace(/\r\n?/g, '\n');
407
+ const raw = index => doc.bom.length + (doc.toRaw ? doc.toRaw[index] : index);
408
+ let reindented = false;
409
+ const pieces = spans.map(span => {
410
+ let text = replacement;
411
+ if (found.oldLines) {
412
+ const matchedLines = doc.table.lines.slice(span.startLine, span.endLine + 1);
413
+ const result = reindent(replacement, found.oldLines, matchedLines, doc.table.lines);
414
+ text = result.text;
415
+ reindented = reindented || result.adjusted;
416
+ }
417
+ if (doc.eol !== '\n') text = text.replace(/\n/g, doc.eol);
418
+ return {start: raw(span.start), end: raw(span.end), startLine: span.startLine + 1, endLine: span.endLine + 1, replacement: text};
419
+ });
420
+ return {ok: true, strategy: found.strategy, pieces, reindented, warnings, ...meta};
421
+ }
422
+
423
+ function splice(text, pieces) {
424
+ let output = '';
425
+ let previous = 0;
426
+ for (const piece of pieces) {
427
+ output += text.slice(previous, piece.start) + piece.replacement;
428
+ previous = piece.end;
429
+ }
430
+ return output + text.slice(previous);
431
+ }
432
+
433
+ function publicMatches(pieces) {
434
+ return pieces.map(({start, end, startLine, endLine}) => ({start, end, startLine, endLine}));
435
+ }
436
+
437
+ function findEdit(text, oldString, options = {}) {
438
+ const doc = prepare(text);
439
+ const result = resolve(doc, {oldString, newString: oldString + '\u0000', replaceAll: true}, options);
440
+ if (!result.ok) return result;
441
+ return {ok: true, strategy: result.strategy, matches: publicMatches(result.pieces), lineEnding: result.lineEnding, bom: result.bom};
442
+ }
443
+
444
+ function applyEdit(text, edit, options = {}) {
445
+ const doc = prepare(text);
446
+ const result = resolve(doc, edit, options);
447
+ if (!result.ok) return result;
448
+ return {
449
+ ok: true,
450
+ text: splice(text, result.pieces),
451
+ strategy: result.strategy,
452
+ replaced: result.pieces.length,
453
+ matches: publicMatches(result.pieces),
454
+ reindented: result.reindented,
455
+ warnings: result.warnings,
456
+ lineEnding: result.lineEnding,
457
+ bom: result.bom,
458
+ };
459
+ }
460
+
461
+ function applyEdits(text, edits, options = {}) {
462
+ if (!Array.isArray(edits) || edits.length === 0) throw new TypeError('edits must be a non-empty array');
463
+ const doc = prepare(text);
464
+ const applied = [];
465
+ const pieces = [];
466
+ for (const [index, edit] of edits.entries()) {
467
+ const result = resolve(doc, edit, options);
468
+ if (!result.ok) return {...result, index};
469
+ applied.push({index, strategy: result.strategy, matches: publicMatches(result.pieces), reindented: result.reindented, warnings: result.warnings});
470
+ for (const piece of result.pieces) pieces.push({...piece, index});
471
+ }
472
+ pieces.sort((a, b) => a.start - b.start || a.index - b.index);
473
+ for (let i = 1; i < pieces.length; i++) {
474
+ 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)) {
475
+ 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 !== ''});
476
+ }
477
+ }
478
+ return {ok: true, text: splice(text, pieces), applied, lineEnding: doc.lineEnding, bom: doc.bom !== ''};
479
+ }
480
+
481
+ module.exports = {findEdit, applyEdit, applyEdits};