ucode-agent 1.61.1 → 1.62.1

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.
@@ -1,295 +1,295 @@
1
- /**
2
- * fuzzy.js — finding the text an edit meant when it is not in the file as written.
3
- *
4
- * Adapted from opencode's edit tool (MIT License, Copyright (c) 2025 opencode;
5
- * see THIRD_PARTY_NOTICES.md).
6
- *
7
- * An old_string that misses costs a whole round trip: the refusal goes back,
8
- * the model re-reads the file, and sends the edit again. Most misses are the
9
- * same handful of slips — a line of the middle remembered slightly wrong,
10
- * whitespace collapsed, the indent shifted, "\n" written out as an escape,
11
- * a stray blank line at either end. Each matcher below yields the spans of the
12
- * file that old_string plausibly means; the first span found exactly once is
13
- * the one replaced. Found in two places is never guessed at.
14
- *
15
- * files.js tries an exact match and its own whitespace-tolerant, re-indenting
16
- * match first; this is only reached when both have missed.
17
- */
18
-
19
- /** How alike the middle lines of a block must be for block anchoring to accept it. */
20
- const SIMILARITY = 0.65;
21
-
22
- /**
23
- * Past this, the file is not searched loosely at all. The matchers are plain
24
- * scans and they run on the main thread, where a stale edit against a huge
25
- * lockfile would freeze the session — Esc included — for minutes.
26
- * ponytail: a flat cap, not a budget; move the chain to a worker if loose
27
- * edits on files this big ever matter.
28
- */
29
- const MAX_LINES = 20_000;
30
- const MAX_CHARS = 1_000_000;
31
-
32
- function levenshtein(a, b) {
33
- if (a === '' || b === '') return Math.max(a.length, b.length);
34
- // Two long minified lines would be a hundred million steps. Lines that long
35
- // are not what a remembered-slightly-wrong edit is about: call them unlike.
36
- if (a.length * b.length > 1_000_000) return a === b ? 0 : Math.max(a.length, b.length);
37
- let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
38
- for (let i = 1; i <= a.length; i++) {
39
- const cur = [i];
40
- for (let j = 1; j <= b.length; j++) {
41
- cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
42
- }
43
- prev = cur;
44
- }
45
- return prev[b.length];
46
- }
47
-
48
- /** The lines of `find`, without the empty string a trailing newline leaves. */
49
- function wantedLines(find) {
50
- const lines = find.split('\n');
51
- if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
52
- return lines;
53
- }
54
-
55
- /** Lines joined into windows before a loose search gives up and calls it a miss. */
56
- const WINDOW_BUDGET = 200_000;
57
-
58
- /**
59
- * Every run of `count` consecutive lines whose first line `could` start a
60
- * match, joined back into text. The check is cheap and rules out nearly every
61
- * window before it is built; the budget bounds the rest.
62
- */
63
- function* windows(lines, count, could = () => true) {
64
- let spent = 0;
65
- for (let i = 0; i + count <= lines.length; i++) {
66
- if (!could(lines[i])) continue;
67
- if ((spent += count) > WINDOW_BUDGET) return;
68
- yield lines.slice(i, i + count).join('\n');
69
- }
70
- }
71
-
72
- /**
73
- * First and last lines exact, the middle close enough. Catches an edit whose
74
- * middle was remembered with a changed word or two — the commonest miss on a
75
- * block the model read many steps ago.
76
- */
77
- function* blockAnchor(content, find) {
78
- if (find.split('\n').length < 3) return;
79
- const want = wantedLines(find);
80
- const lines = content.split('\n');
81
- const first = want[0].trim();
82
- const last = want[want.length - 1].trim();
83
- const slack = Math.max(1, Math.floor(want.length * 0.25));
84
-
85
- // Only the first closing line after an opening one counts, and a block is
86
- // accepted within `slack` of the wanted length — so there is no point
87
- // looking further than that. Scanning on to the end of the file made a
88
- // blank first and last line quadratic in the file's length.
89
- const blocks = [];
90
- for (let i = 0; i < lines.length; i++) {
91
- if (lines[i].trim() !== first) continue;
92
- const end = Math.min(lines.length, i + want.length + slack);
93
- for (let j = i + 2; j < end; j++) {
94
- if (lines[j].trim() !== last) continue;
95
- if (Math.abs(j - i + 1 - want.length) <= slack) blocks.push([i, j]);
96
- break;
97
- }
98
- }
99
-
100
- const similarity = ([i, j]) => {
101
- const inner = Math.min(want.length - 2, j - i - 1);
102
- if (inner <= 0) return 1;
103
- let total = 0;
104
- for (let k = 1; k < want.length - 1 && k < j - i; k++) {
105
- const a = lines[i + k].trim();
106
- const b = want[k].trim();
107
- const longest = Math.max(a.length, b.length);
108
- if (longest) total += 1 - levenshtein(a, b) / longest;
109
- }
110
- return total / inner;
111
- };
112
-
113
- // opencode takes the best-scoring block. Every block close enough is
114
- // offered instead, so two look-alike handlers read as ambiguous and are
115
- // refused rather than the first one quietly edited.
116
- for (const block of blocks) {
117
- if (similarity(block) >= SIMILARITY) yield lines.slice(block[0], block[1] + 1).join('\n');
118
- }
119
- }
120
- blockAnchor.how = 'by its first and last lines, the middle nearly the same';
121
-
122
- /** Every run of whitespace treated as one space. */
123
- function* whitespaceNormalized(content, find) {
124
- const flat = (s) => s.replace(/\s+/g, ' ').trim();
125
- const want = flat(find);
126
- if (!want) return;
127
- const lines = content.split('\n');
128
-
129
- for (const line of lines) {
130
- const flatLine = flat(line);
131
- if (flatLine === want) {
132
- yield line;
133
- } else if (flatLine.includes(want)) {
134
- const pattern = find.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+');
135
- const hit = line.match(new RegExp(pattern));
136
- if (hit) yield hit[0];
137
- }
138
- }
139
-
140
- const count = find.split('\n').length;
141
- if (count > 1) {
142
- for (const block of windows(lines, count, (l) => want.startsWith(flat(l)))) if (flat(block) === want) yield block;
143
- }
144
- }
145
- whitespaceNormalized.how = 'with runs of whitespace collapsed';
146
-
147
- /** The same block at a different indent depth. */
148
- function* indentationFlexible(content, find) {
149
- const dedent = (s) => {
150
- const lines = s.split('\n');
151
- const filled = lines.filter((l) => l.trim());
152
- if (!filled.length) return s;
153
- const least = Math.min(...filled.map((l) => /^\s*/.exec(l)[0].length));
154
- return lines.map((l) => (l.trim() ? l.slice(least) : l)).join('\n');
155
- };
156
- const want = dedent(find);
157
- // Dedenting keeps every line's text, so the first lines must match trimmed.
158
- const head = find.split('\n')[0].trim();
159
- for (const block of windows(content.split('\n'), find.split('\n').length, (l) => l.trim() === head)) {
160
- if (dedent(block) === want) yield block;
161
- }
162
- }
163
- indentationFlexible.how = 'ignoring indentation';
164
-
165
- const ESCAPES = { n: '\n', t: '\t', r: '\r', "'": "'", '"': '"', '`': '`', '\\': '\\', '\n': '\n', $: '$' };
166
- const unescape = (s) => s.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_, c) => ESCAPES[c]);
167
-
168
- /** "\n" and "\"" written out as escapes, where the file has the real characters. */
169
- function* escapeNormalized(content, find) {
170
- const want = unescape(find);
171
- if (content.includes(want)) yield want;
172
- // A trailing backslash may be escaping the line break, so it is left off.
173
- const could = (l) => want.startsWith(unescape(l).replace(/\\$/, ''));
174
- for (const block of windows(content.split('\n'), want.split('\n').length, could)) {
175
- if (unescape(block) === want) yield block;
176
- }
177
- }
178
- escapeNormalized.how = 'after undoing escaped characters';
179
- // A model that wrote old_string with "\n" for its line breaks wrote
180
- // new_string the same way; spliced in as is, the file would get a literal
181
- // backslash-n where each line break should be. A new_string with real line
182
- // breaks is already in the file's terms and is left alone.
183
- escapeNormalized.adapt = (replacement, find) =>
184
- unescape(find) !== find && !replacement.includes('\n') ? unescape(replacement) : replacement;
185
-
186
- /** Blank lines or spaces at either end that the file does not have there. */
187
- function* trimmedBoundary(content, find) {
188
- const want = find.trim();
189
- if (want === find || !want) return;
190
- if (content.includes(want)) yield want;
191
- for (const block of windows(content.split('\n'), find.split('\n').length, (l) => want.startsWith(l.trimStart()))) {
192
- if (block.trim() === want) yield block;
193
- }
194
- }
195
- trimmedBoundary.how = 'ignoring leading and trailing whitespace';
196
-
197
- /** First and last lines exact, the same length, and at least half the middle identical. */
198
- function* contextAware(content, find) {
199
- if (find.split('\n').length < 3) return;
200
- const want = wantedLines(find);
201
- const lines = content.split('\n');
202
- const first = want[0].trim();
203
- const last = want[want.length - 1].trim();
204
-
205
- for (let i = 0; i < lines.length; i++) {
206
- if (lines[i].trim() !== first) continue;
207
- // Only a block exactly as long as the one wanted is accepted.
208
- const end = Math.min(lines.length, i + want.length);
209
- for (let j = i + 2; j < end; j++) {
210
- if (lines[j].trim() !== last) continue;
211
- const block = lines.slice(i, j + 1);
212
- if (block.length === want.length) {
213
- let same = 0;
214
- let counted = 0;
215
- for (let k = 1; k < block.length - 1; k++) {
216
- const a = block[k].trim();
217
- const b = want[k].trim();
218
- if (a || b) {
219
- counted++;
220
- if (a === b) same++;
221
- }
222
- }
223
- if (counted === 0 || same / counted >= 0.5) yield block.join('\n');
224
- }
225
- break;
226
- }
227
- }
228
- }
229
- contextAware.how = 'by its first and last lines';
230
-
231
- const MATCHERS = [blockAnchor, whitespaceNormalized, indentationFlexible, escapeNormalized, trimmedBoundary, contextAware];
232
-
233
- /**
234
- * A match far bigger than what was asked for is a matcher reaching, not a
235
- * find. Replacing it would delete code the model never meant to touch.
236
- */
237
- function tooWide(match, find) {
238
- const lines = find.split('\n').length;
239
- if (match.split('\n').length >= Math.max(lines + 3, lines * 2)) return true;
240
- if (lines === 1) return false;
241
- return match.trim().length > Math.max(find.trim().length + 500, find.trim().length * 4);
242
- }
243
-
244
- /** How many separate places a set of [start, end) spans covers, overlaps counted once. */
245
- function places(spans) {
246
- let count = 0;
247
- let reach = -1;
248
- for (const [start, end] of spans.sort((a, b) => a[0] - b[0])) {
249
- if (start >= reach) count++;
250
- reach = Math.max(reach, end);
251
- }
252
- return count;
253
- }
254
-
255
- /**
256
- * Replace what `find` loosely means in `content`.
257
- *
258
- * `find` and `replacement` should already use the file's line endings; the
259
- * file itself is never re-ended, so a mixed file keeps every line it had.
260
- * Returns { text, index, count, how } on success, { ambiguous: true } when the
261
- * first matcher to find anything finds it in more than one place,
262
- * { wide: true } when the match is far bigger than `find`, and null when
263
- * nothing matched at all. With `all`, every copy of the matched span changes.
264
- *
265
- * Unlike opencode, a matcher that finds two places is the end of it: a looser
266
- * matcher further down picking one of them would be a guess.
267
- */
268
- export function fuzzyReplace(content, find, replacement, { all = false } = {}) {
269
- if (!find || content.length > MAX_CHARS || find.length > MAX_CHARS) return null;
270
- if (content.split('\n', MAX_LINES + 1).length > MAX_LINES) return null;
271
- for (const matcher of MATCHERS) {
272
- // Spans built from whole lines of a \r\n file end on the last line's \r.
273
- // That \r belongs to the line break after the match, which stays.
274
- const own = (m) => (m.endsWith('\r') && !find.endsWith('\r') ? m.slice(0, -1) : m);
275
- const hits = [...new Set([...matcher(content, find)].map(own))].filter((m) => m && content.includes(m));
276
- if (!hits.length) continue;
277
-
278
- const pick = hits[0];
279
- if (tooWide(pick, find)) return { wide: true };
280
- const text = matcher.adapt ? matcher.adapt(replacement, find) : replacement;
281
- const index = content.indexOf(pick);
282
- if (all) {
283
- const copies = content.split(pick);
284
- return { text: copies.join(text), index, count: copies.length - 1, how: matcher.how };
285
- }
286
-
287
- const spans = [];
288
- for (const m of hits) {
289
- for (let i = content.indexOf(m); i !== -1; i = content.indexOf(m, i + 1)) spans.push([i, i + m.length]);
290
- }
291
- if (places(spans) > 1) return { ambiguous: true };
292
- return { text: content.slice(0, index) + text + content.slice(index + pick.length), index, count: 1, how: matcher.how };
293
- }
294
- return null;
295
- }
1
+ /**
2
+ * fuzzy.js — finding the text an edit meant when it is not in the file as written.
3
+ *
4
+ * Adapted from opencode's edit tool (MIT License, Copyright (c) 2025 opencode;
5
+ * see THIRD_PARTY_NOTICES.md).
6
+ *
7
+ * An old_string that misses costs a whole round trip: the refusal goes back,
8
+ * the model re-reads the file, and sends the edit again. Most misses are the
9
+ * same handful of slips — a line of the middle remembered slightly wrong,
10
+ * whitespace collapsed, the indent shifted, "\n" written out as an escape,
11
+ * a stray blank line at either end. Each matcher below yields the spans of the
12
+ * file that old_string plausibly means; the first span found exactly once is
13
+ * the one replaced. Found in two places is never guessed at.
14
+ *
15
+ * files.js tries an exact match and its own whitespace-tolerant, re-indenting
16
+ * match first; this is only reached when both have missed.
17
+ */
18
+
19
+ /** How alike the middle lines of a block must be for block anchoring to accept it. */
20
+ const SIMILARITY = 0.65;
21
+
22
+ /**
23
+ * Past this, the file is not searched loosely at all. The matchers are plain
24
+ * scans and they run on the main thread, where a stale edit against a huge
25
+ * lockfile would freeze the session — Esc included — for minutes.
26
+ * ponytail: a flat cap, not a budget; move the chain to a worker if loose
27
+ * edits on files this big ever matter.
28
+ */
29
+ const MAX_LINES = 20_000;
30
+ const MAX_CHARS = 1_000_000;
31
+
32
+ function levenshtein(a, b) {
33
+ if (a === '' || b === '') return Math.max(a.length, b.length);
34
+ // Two long minified lines would be a hundred million steps. Lines that long
35
+ // are not what a remembered-slightly-wrong edit is about: call them unlike.
36
+ if (a.length * b.length > 1_000_000) return a === b ? 0 : Math.max(a.length, b.length);
37
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
38
+ for (let i = 1; i <= a.length; i++) {
39
+ const cur = [i];
40
+ for (let j = 1; j <= b.length; j++) {
41
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
42
+ }
43
+ prev = cur;
44
+ }
45
+ return prev[b.length];
46
+ }
47
+
48
+ /** The lines of `find`, without the empty string a trailing newline leaves. */
49
+ function wantedLines(find) {
50
+ const lines = find.split('\n');
51
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
52
+ return lines;
53
+ }
54
+
55
+ /** Lines joined into windows before a loose search gives up and calls it a miss. */
56
+ const WINDOW_BUDGET = 200_000;
57
+
58
+ /**
59
+ * Every run of `count` consecutive lines whose first line `could` start a
60
+ * match, joined back into text. The check is cheap and rules out nearly every
61
+ * window before it is built; the budget bounds the rest.
62
+ */
63
+ function* windows(lines, count, could = () => true) {
64
+ let spent = 0;
65
+ for (let i = 0; i + count <= lines.length; i++) {
66
+ if (!could(lines[i])) continue;
67
+ if ((spent += count) > WINDOW_BUDGET) return;
68
+ yield lines.slice(i, i + count).join('\n');
69
+ }
70
+ }
71
+
72
+ /**
73
+ * First and last lines exact, the middle close enough. Catches an edit whose
74
+ * middle was remembered with a changed word or two — the commonest miss on a
75
+ * block the model read many steps ago.
76
+ */
77
+ function* blockAnchor(content, find) {
78
+ if (find.split('\n').length < 3) return;
79
+ const want = wantedLines(find);
80
+ const lines = content.split('\n');
81
+ const first = want[0].trim();
82
+ const last = want[want.length - 1].trim();
83
+ const slack = Math.max(1, Math.floor(want.length * 0.25));
84
+
85
+ // Only the first closing line after an opening one counts, and a block is
86
+ // accepted within `slack` of the wanted length — so there is no point
87
+ // looking further than that. Scanning on to the end of the file made a
88
+ // blank first and last line quadratic in the file's length.
89
+ const blocks = [];
90
+ for (let i = 0; i < lines.length; i++) {
91
+ if (lines[i].trim() !== first) continue;
92
+ const end = Math.min(lines.length, i + want.length + slack);
93
+ for (let j = i + 2; j < end; j++) {
94
+ if (lines[j].trim() !== last) continue;
95
+ if (Math.abs(j - i + 1 - want.length) <= slack) blocks.push([i, j]);
96
+ break;
97
+ }
98
+ }
99
+
100
+ const similarity = ([i, j]) => {
101
+ const inner = Math.min(want.length - 2, j - i - 1);
102
+ if (inner <= 0) return 1;
103
+ let total = 0;
104
+ for (let k = 1; k < want.length - 1 && k < j - i; k++) {
105
+ const a = lines[i + k].trim();
106
+ const b = want[k].trim();
107
+ const longest = Math.max(a.length, b.length);
108
+ if (longest) total += 1 - levenshtein(a, b) / longest;
109
+ }
110
+ return total / inner;
111
+ };
112
+
113
+ // opencode takes the best-scoring block. Every block close enough is
114
+ // offered instead, so two look-alike handlers read as ambiguous and are
115
+ // refused rather than the first one quietly edited.
116
+ for (const block of blocks) {
117
+ if (similarity(block) >= SIMILARITY) yield lines.slice(block[0], block[1] + 1).join('\n');
118
+ }
119
+ }
120
+ blockAnchor.how = 'by its first and last lines, the middle nearly the same';
121
+
122
+ /** Every run of whitespace treated as one space. */
123
+ function* whitespaceNormalized(content, find) {
124
+ const flat = (s) => s.replace(/\s+/g, ' ').trim();
125
+ const want = flat(find);
126
+ if (!want) return;
127
+ const lines = content.split('\n');
128
+
129
+ for (const line of lines) {
130
+ const flatLine = flat(line);
131
+ if (flatLine === want) {
132
+ yield line;
133
+ } else if (flatLine.includes(want)) {
134
+ const pattern = find.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+');
135
+ const hit = line.match(new RegExp(pattern));
136
+ if (hit) yield hit[0];
137
+ }
138
+ }
139
+
140
+ const count = find.split('\n').length;
141
+ if (count > 1) {
142
+ for (const block of windows(lines, count, (l) => want.startsWith(flat(l)))) if (flat(block) === want) yield block;
143
+ }
144
+ }
145
+ whitespaceNormalized.how = 'with runs of whitespace collapsed';
146
+
147
+ /** The same block at a different indent depth. */
148
+ function* indentationFlexible(content, find) {
149
+ const dedent = (s) => {
150
+ const lines = s.split('\n');
151
+ const filled = lines.filter((l) => l.trim());
152
+ if (!filled.length) return s;
153
+ const least = Math.min(...filled.map((l) => /^\s*/.exec(l)[0].length));
154
+ return lines.map((l) => (l.trim() ? l.slice(least) : l)).join('\n');
155
+ };
156
+ const want = dedent(find);
157
+ // Dedenting keeps every line's text, so the first lines must match trimmed.
158
+ const head = find.split('\n')[0].trim();
159
+ for (const block of windows(content.split('\n'), find.split('\n').length, (l) => l.trim() === head)) {
160
+ if (dedent(block) === want) yield block;
161
+ }
162
+ }
163
+ indentationFlexible.how = 'ignoring indentation';
164
+
165
+ const ESCAPES = { n: '\n', t: '\t', r: '\r', "'": "'", '"': '"', '`': '`', '\\': '\\', '\n': '\n', $: '$' };
166
+ const unescape = (s) => s.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_, c) => ESCAPES[c]);
167
+
168
+ /** "\n" and "\"" written out as escapes, where the file has the real characters. */
169
+ function* escapeNormalized(content, find) {
170
+ const want = unescape(find);
171
+ if (content.includes(want)) yield want;
172
+ // A trailing backslash may be escaping the line break, so it is left off.
173
+ const could = (l) => want.startsWith(unescape(l).replace(/\\$/, ''));
174
+ for (const block of windows(content.split('\n'), want.split('\n').length, could)) {
175
+ if (unescape(block) === want) yield block;
176
+ }
177
+ }
178
+ escapeNormalized.how = 'after undoing escaped characters';
179
+ // A model that wrote old_string with "\n" for its line breaks wrote
180
+ // new_string the same way; spliced in as is, the file would get a literal
181
+ // backslash-n where each line break should be. A new_string with real line
182
+ // breaks is already in the file's terms and is left alone.
183
+ escapeNormalized.adapt = (replacement, find) =>
184
+ unescape(find) !== find && !replacement.includes('\n') ? unescape(replacement) : replacement;
185
+
186
+ /** Blank lines or spaces at either end that the file does not have there. */
187
+ function* trimmedBoundary(content, find) {
188
+ const want = find.trim();
189
+ if (want === find || !want) return;
190
+ if (content.includes(want)) yield want;
191
+ for (const block of windows(content.split('\n'), find.split('\n').length, (l) => want.startsWith(l.trimStart()))) {
192
+ if (block.trim() === want) yield block;
193
+ }
194
+ }
195
+ trimmedBoundary.how = 'ignoring leading and trailing whitespace';
196
+
197
+ /** First and last lines exact, the same length, and at least half the middle identical. */
198
+ function* contextAware(content, find) {
199
+ if (find.split('\n').length < 3) return;
200
+ const want = wantedLines(find);
201
+ const lines = content.split('\n');
202
+ const first = want[0].trim();
203
+ const last = want[want.length - 1].trim();
204
+
205
+ for (let i = 0; i < lines.length; i++) {
206
+ if (lines[i].trim() !== first) continue;
207
+ // Only a block exactly as long as the one wanted is accepted.
208
+ const end = Math.min(lines.length, i + want.length);
209
+ for (let j = i + 2; j < end; j++) {
210
+ if (lines[j].trim() !== last) continue;
211
+ const block = lines.slice(i, j + 1);
212
+ if (block.length === want.length) {
213
+ let same = 0;
214
+ let counted = 0;
215
+ for (let k = 1; k < block.length - 1; k++) {
216
+ const a = block[k].trim();
217
+ const b = want[k].trim();
218
+ if (a || b) {
219
+ counted++;
220
+ if (a === b) same++;
221
+ }
222
+ }
223
+ if (counted === 0 || same / counted >= 0.5) yield block.join('\n');
224
+ }
225
+ break;
226
+ }
227
+ }
228
+ }
229
+ contextAware.how = 'by its first and last lines';
230
+
231
+ const MATCHERS = [blockAnchor, whitespaceNormalized, indentationFlexible, escapeNormalized, trimmedBoundary, contextAware];
232
+
233
+ /**
234
+ * A match far bigger than what was asked for is a matcher reaching, not a
235
+ * find. Replacing it would delete code the model never meant to touch.
236
+ */
237
+ function tooWide(match, find) {
238
+ const lines = find.split('\n').length;
239
+ if (match.split('\n').length >= Math.max(lines + 3, lines * 2)) return true;
240
+ if (lines === 1) return false;
241
+ return match.trim().length > Math.max(find.trim().length + 500, find.trim().length * 4);
242
+ }
243
+
244
+ /** How many separate places a set of [start, end) spans covers, overlaps counted once. */
245
+ function places(spans) {
246
+ let count = 0;
247
+ let reach = -1;
248
+ for (const [start, end] of spans.sort((a, b) => a[0] - b[0])) {
249
+ if (start >= reach) count++;
250
+ reach = Math.max(reach, end);
251
+ }
252
+ return count;
253
+ }
254
+
255
+ /**
256
+ * Replace what `find` loosely means in `content`.
257
+ *
258
+ * `find` and `replacement` should already use the file's line endings; the
259
+ * file itself is never re-ended, so a mixed file keeps every line it had.
260
+ * Returns { text, index, count, how } on success, { ambiguous: true } when the
261
+ * first matcher to find anything finds it in more than one place,
262
+ * { wide: true } when the match is far bigger than `find`, and null when
263
+ * nothing matched at all. With `all`, every copy of the matched span changes.
264
+ *
265
+ * Unlike opencode, a matcher that finds two places is the end of it: a looser
266
+ * matcher further down picking one of them would be a guess.
267
+ */
268
+ export function fuzzyReplace(content, find, replacement, { all = false } = {}) {
269
+ if (!find || content.length > MAX_CHARS || find.length > MAX_CHARS) return null;
270
+ if (content.split('\n', MAX_LINES + 1).length > MAX_LINES) return null;
271
+ for (const matcher of MATCHERS) {
272
+ // Spans built from whole lines of a \r\n file end on the last line's \r.
273
+ // That \r belongs to the line break after the match, which stays.
274
+ const own = (m) => (m.endsWith('\r') && !find.endsWith('\r') ? m.slice(0, -1) : m);
275
+ const hits = [...new Set([...matcher(content, find)].map(own))].filter((m) => m && content.includes(m));
276
+ if (!hits.length) continue;
277
+
278
+ const pick = hits[0];
279
+ if (tooWide(pick, find)) return { wide: true };
280
+ const text = matcher.adapt ? matcher.adapt(replacement, find) : replacement;
281
+ const index = content.indexOf(pick);
282
+ if (all) {
283
+ const copies = content.split(pick);
284
+ return { text: copies.join(text), index, count: copies.length - 1, how: matcher.how };
285
+ }
286
+
287
+ const spans = [];
288
+ for (const m of hits) {
289
+ for (let i = content.indexOf(m); i !== -1; i = content.indexOf(m, i + 1)) spans.push([i, i + m.length]);
290
+ }
291
+ if (places(spans) > 1) return { ambiguous: true };
292
+ return { text: content.slice(0, index) + text + content.slice(index + pick.length), index, count: 1, how: matcher.how };
293
+ }
294
+ return null;
295
+ }