@motionscript/code 0.1.0-alpha.8 → 0.1.0-alpha.9

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/src/diff.ts CHANGED
@@ -1,351 +1,351 @@
1
- import type { IdLine, IdToken } from "./tokens";
2
-
3
- /**
4
- * The result of diffing one listing structure against another: the *next*
5
- * structure, with token and line ids carried over from the previous one
6
- * wherever the content survived, plus the three sets a transition needs to know
7
- * which role each token plays.
8
- *
9
- * Identity is the whole product here. `const variable = 3` becoming
10
- * `const number = 3` should move `const` and `= 3` rather than dissolve and
11
- * rebuild them, and a token can only *move* if the frame after the edit still
12
- * knows it is the same token.
13
- */
14
- /**
15
- * How a `Code` node decides which token became which across an edit. Given
16
- * the old and new tokenized structures, return the carried-over structure
17
- * plus the added/removed/renamed-line bookkeeping a transition needs.
18
- * `diffCode` below is the default implementation (exported as
19
- * `defaultCodeDiff`) — pass a `diffStrategy` of this shape to `<Code>` to
20
- * replace it with your own.
21
- */
22
- export type CodeDiffStrategy = (from: IdLine[], to: IdLine[]) => CodeEdit;
23
-
24
- export interface CodeEdit {
25
- /** The new structure. Ids are rewritten in place onto the tokens handed in. */
26
- lines: IdLine[];
27
- /** Ids present only in the old structure — these fade out. */
28
- removedIds: Set<number>;
29
- /** Ids present only in the new structure — these fade in. */
30
- addedIds: Set<number>;
31
- /** Ids of {@link lines} that did not exist before — these enter as a whole row. */
32
- newLineIds: Set<number>;
33
- /** Previous colour of a surviving token whose highlighting changed, for the cross-fade. */
34
- fromColorById: Map<number, string | undefined>;
35
- }
36
-
37
- /**
38
- * How similar two lines must be before they are treated as *one line that was
39
- * edited* rather than as one line deleted and another inserted.
40
- *
41
- * Below this, a row's tokens have so little in common that carrying ids across
42
- * would animate glyphs sliding between unrelated words. Above it, the edit reads
43
- * the way it was made.
44
- */
45
- const LINE_MATCH_FLOOR = 0.45;
46
-
47
- /**
48
- * The most line pairs one run of changed lines is scored against each other, and
49
- * the most across a whole edit.
50
- *
51
- * Scoring every pair is quadratic: with 50-character lines it took 31 ms at 200
52
- * lines and 741 ms at 1,000, paid again for each edit command when it is built.
53
- * So the lines are first paired where they cannot be in doubt (see
54
- * {@link alignLines}), and only a run left between two such anchors is scored.
55
- * A run past the first limit, or what is left of the second, is not compared: its
56
- * lines leave and enter as whole rows. That is the budget, and an edit that stays
57
- * inside it is diffed exactly as before.
58
- */
59
- export const LINE_ALIGN_CELLS = 40_000;
60
- export const EDIT_ALIGN_CELLS = 160_000;
61
-
62
- /** Weight a token carries when judging similarity: its visible characters. */
63
- function tokenWeight(tok: IdToken): number {
64
- return tok.content.trim().length;
65
- }
66
-
67
- function lineWeight(line: IdLine): number {
68
- let sum = 0;
69
- for (const tok of line.tokens) sum += tokenWeight(tok);
70
- return sum;
71
- }
72
-
73
- /**
74
- * Dice coefficient over the two lines' token multisets, weighted by visible
75
- * characters so `variable` counts for far more than a run of spaces.
76
- *
77
- * Two blank lines are identical (1); a blank line against a non-blank one shares
78
- * nothing (0), which keeps the blank rows a reformat introduces from anchoring
79
- * themselves onto real code.
80
- */
81
- function similarity(a: IdLine, b: IdLine): number {
82
- const wa = lineWeight(a);
83
- const wb = lineWeight(b);
84
- if (wa === 0 && wb === 0) return 1;
85
- if (wa === 0 || wb === 0) return 0;
86
-
87
- const bag = new Map<string, number>();
88
- for (const tok of a.tokens) {
89
- if (tokenWeight(tok) === 0) continue;
90
- bag.set(tok.content, (bag.get(tok.content) ?? 0) + 1);
91
- }
92
- let common = 0;
93
- for (const tok of b.tokens) {
94
- const w = tokenWeight(tok);
95
- if (w === 0) continue;
96
- const left = bag.get(tok.content) ?? 0;
97
- if (left > 0) {
98
- bag.set(tok.content, left - 1);
99
- common += w;
100
- }
101
- }
102
- return (2 * common) / (wa + wb);
103
- }
104
-
105
- /**
106
- * Align two sequences by maximising total `score`, skipping is free.
107
- *
108
- * A plain LCS would only ever pair *identical* elements, which is the wrong tool
109
- * twice over: two lines that differ by one word are the same line, and two
110
- * tokens that differ only in colour are the same token. Scoring the pairing and
111
- * letting anything that scores ≤ 0 fall out expresses that directly, and because
112
- * skips cost nothing the alignment stays in order.
113
- *
114
- * Returns the matched index pairs, in order.
115
- */
116
- function align(
117
- aLength: number,
118
- bLength: number,
119
- score: (i: number, j: number) => number,
120
- ): Array<[number, number]> {
121
- // dp[i][j] = best score aligning a[0..i) with b[0..j).
122
- const width = bLength + 1;
123
- const dp = new Float64Array((aLength + 1) * width);
124
- const pairScore = new Float64Array(aLength * bLength);
125
-
126
- for (let i = 1; i <= aLength; i++) {
127
- for (let j = 1; j <= bLength; j++) {
128
- const s = score(i - 1, j - 1);
129
- pairScore[(i - 1) * bLength + (j - 1)] = s;
130
- const skipA = dp[(i - 1) * width + j];
131
- const skipB = dp[i * width + (j - 1)];
132
- let best = skipA > skipB ? skipA : skipB;
133
- if (s > 0) {
134
- const paired = dp[(i - 1) * width + (j - 1)] + s;
135
- if (paired > best) best = paired;
136
- }
137
- dp[i * width + j] = best;
138
- }
139
- }
140
-
141
- const pairs: Array<[number, number]> = [];
142
- let i = aLength;
143
- let j = bLength;
144
- while (i > 0 && j > 0) {
145
- const s = pairScore[(i - 1) * bLength + (j - 1)];
146
- if (s > 0 && dp[i * width + j] === dp[(i - 1) * width + (j - 1)] + s) {
147
- pairs.push([i - 1, j - 1]);
148
- i--; j--;
149
- } else if (dp[(i - 1) * width + j] >= dp[i * width + (j - 1)]) {
150
- i--;
151
- } else {
152
- j--;
153
- }
154
- }
155
- pairs.reverse();
156
- return pairs;
157
- }
158
-
159
- /**
160
- * Match the tokens of one line against another's.
161
- *
162
- * Scored by visible characters rather than token count, so an alignment that
163
- * keeps `items.reduce` together beats one that keeps four more spaces. A token
164
- * pairs only when its content is unchanged — a changed word is a different word,
165
- * and should be seen to be replaced.
166
- */
167
- function alignTokens(from: IdToken[], to: IdToken[]): Array<[number, number]> {
168
- return align(from.length, to.length, (i, j) => {
169
- if (from[i].content !== to[j].content) return 0;
170
- // Whitespace still pairs (it holds indentation together), just weakly
171
- // enough that it never outranks real content.
172
- return tokenWeight(to[j]) || 0.25;
173
- });
174
- }
175
-
176
- function sameTokens(a: IdLine, b: IdLine): boolean {
177
- if (a.tokens.length !== b.tokens.length) return false;
178
- for (let k = 0; k < a.tokens.length; k++) if (a.tokens[k].content !== b.tokens[k].content) return false;
179
- return true;
180
- }
181
-
182
- /** A line's text, token boundaries included, for telling identical lines apart from similar ones. */
183
- function signature(line: IdLine): string {
184
- let out = "";
185
- for (const tok of line.tokens) out += tok.content + "\u0001";
186
- return out;
187
- }
188
-
189
- /**
190
- * Pair the lines of two listings, in order.
191
- *
192
- * Lines that are the same at the start and at the end are paired outright. In
193
- * what is left, a line that occurs exactly once on each side is an anchor, and
194
- * the longest run of anchors that keeps their order splits the rest into runs of
195
- * changed lines, each scored against itself within the budget (see
196
- * {@link LINE_ALIGN_CELLS}). A one-line edit to a long listing therefore never
197
- * builds a table bigger than the lines around it.
198
- */
199
- function alignLines(from: IdLine[], to: IdLine[]): Array<[number, number]> {
200
- const fromSig = from.map(signature);
201
- const toSig = to.map(signature);
202
- const pairs: Array<[number, number]> = [];
203
-
204
- let head = 0;
205
- while (head < from.length && head < to.length && fromSig[head] === toSig[head]) {
206
- pairs.push([head, head]);
207
- head++;
208
- }
209
- let tail = 0;
210
- while (
211
- tail < from.length - head && tail < to.length - head
212
- && fromSig[from.length - 1 - tail] === toSig[to.length - 1 - tail]
213
- ) tail++;
214
-
215
- const anchors = anchorLines(fromSig, toSig, head, from.length - tail, head, to.length - tail);
216
- let budget = EDIT_ALIGN_CELLS;
217
- let fi = head;
218
- let ti = head;
219
- for (const [af, at] of [...anchors, [from.length - tail, to.length - tail] as [number, number]]) {
220
- const rows = af - fi;
221
- const cols = at - ti;
222
- const cells = rows * cols;
223
- if (cells > 0 && cells <= LINE_ALIGN_CELLS && cells <= budget) {
224
- budget -= cells;
225
- for (const [i, j] of align(rows, cols, (a, b) => similarity(from[fi + a], to[ti + b]) - LINE_MATCH_FLOOR)) {
226
- pairs.push([fi + i, ti + j]);
227
- }
228
- }
229
- if (af < from.length - tail) pairs.push([af, at]);
230
- fi = af + 1;
231
- ti = at + 1;
232
- }
233
- for (let k = tail; k > 0; k--) pairs.push([from.length - k, to.length - k]);
234
- return pairs;
235
- }
236
-
237
- /**
238
- * The lines that occur once in `from[fa, fb)` and once in `to[ta, tb)`, reduced to
239
- * the longest subsequence that is increasing on both sides (patience sorting).
240
- * Blank lines are never anchors: they say nothing about where they belong.
241
- */
242
- function anchorLines(
243
- fromSig: string[],
244
- toSig: string[],
245
- fa: number,
246
- fb: number,
247
- ta: number,
248
- tb: number,
249
- ): Array<[number, number]> {
250
- const fromAt = new Map<string, number>();
251
- const seen = new Set<string>();
252
- for (let i = fa; i < fb; i++) {
253
- const sig = fromSig[i];
254
- if (seen.has(sig)) fromAt.delete(sig);
255
- else fromAt.set(sig, i);
256
- seen.add(sig);
257
- }
258
- const toAt = new Map<string, number>();
259
- const seenTo = new Set<string>();
260
- for (let j = ta; j < tb; j++) {
261
- const sig = toSig[j];
262
- if (seenTo.has(sig)) toAt.delete(sig);
263
- else toAt.set(sig, j);
264
- seenTo.add(sig);
265
- }
266
-
267
- // In `to` order, each candidate carries its `from` position.
268
- const candidates: Array<[number, number]> = [];
269
- for (let j = ta; j < tb; j++) {
270
- const i = fromAt.get(toSig[j]);
271
- if (i !== undefined && toAt.get(toSig[j]) === j && toSig[j].replaceAll("\u0001", "").trim() !== "") {
272
- candidates.push([i, j]);
273
- }
274
- }
275
-
276
- const tails: number[] = [];
277
- const previous: number[] = new Array(candidates.length).fill(-1);
278
- for (let c = 0; c < candidates.length; c++) {
279
- let lo = 0;
280
- let hi = tails.length;
281
- while (lo < hi) {
282
- const mid = (lo + hi) >> 1;
283
- if (candidates[tails[mid]][0] < candidates[c][0]) lo = mid + 1;
284
- else hi = mid;
285
- }
286
- if (lo > 0) previous[c] = tails[lo - 1];
287
- tails[lo] = c;
288
- }
289
- const chain: Array<[number, number]> = [];
290
- for (let c = tails.length > 0 ? tails[tails.length - 1] : -1; c >= 0; c = previous[c]) chain.push(candidates[c]);
291
- return chain.reverse();
292
- }
293
-
294
- /**
295
- * Carry `from`'s identities onto `to`, and report what changed.
296
- *
297
- * `to` is freshly tokenized and owns ids nobody has seen yet, so rewriting them
298
- * in place is safe and is what makes the transition cheap: after this, the two
299
- * structures share an id for every token that survived the edit.
300
- */
301
- export function diffCode(from: IdLine[], to: IdLine[]): CodeEdit {
302
- const removedIds = new Set<number>();
303
- const addedIds = new Set<number>();
304
- const newLineIds = new Set<number>();
305
- const fromColorById = new Map<number, string | undefined>();
306
-
307
- const linePairs = alignLines(from, to);
308
-
309
- const pairedTo = new Set<number>();
310
- const pairedFrom = new Set<number>();
311
-
312
- for (const [fi, ti] of linePairs) {
313
- pairedFrom.add(fi);
314
- pairedTo.add(ti);
315
- const fromLine = from[fi];
316
- const toLine = to[ti];
317
- toLine.id = fromLine.id;
318
-
319
- const matched = sameTokens(fromLine, toLine)
320
- ? fromLine.tokens.map((_, k): [number, number] => [k, k])
321
- : alignTokens(fromLine.tokens, toLine.tokens);
322
- const keptFrom = new Set<number>();
323
- const keptTo = new Set<number>();
324
- for (const [a, b] of matched) {
325
- keptFrom.add(a);
326
- keptTo.add(b);
327
- const oldTok = fromLine.tokens[a];
328
- const newTok = toLine.tokens[b];
329
- newTok.id = oldTok.id;
330
- if (oldTok.color !== newTok.color) fromColorById.set(oldTok.id, oldTok.color);
331
- }
332
- for (let a = 0; a < fromLine.tokens.length; a++) {
333
- if (!keptFrom.has(a)) removedIds.add(fromLine.tokens[a].id);
334
- }
335
- for (let b = 0; b < toLine.tokens.length; b++) {
336
- if (!keptTo.has(b)) addedIds.add(toLine.tokens[b].id);
337
- }
338
- }
339
-
340
- for (let i = 0; i < from.length; i++) {
341
- if (pairedFrom.has(i)) continue;
342
- for (const tok of from[i].tokens) removedIds.add(tok.id);
343
- }
344
- for (let j = 0; j < to.length; j++) {
345
- if (pairedTo.has(j)) continue;
346
- newLineIds.add(to[j].id);
347
- for (const tok of to[j].tokens) addedIds.add(tok.id);
348
- }
349
-
350
- return { lines: to, removedIds, addedIds, newLineIds, fromColorById };
351
- }
1
+ import type { IdLine, IdToken } from "./tokens";
2
+
3
+ /**
4
+ * The result of diffing one listing structure against another: the *next*
5
+ * structure, with token and line ids carried over from the previous one
6
+ * wherever the content survived, plus the three sets a transition needs to know
7
+ * which role each token plays.
8
+ *
9
+ * Identity is the whole product here. `const variable = 3` becoming
10
+ * `const number = 3` should move `const` and `= 3` rather than dissolve and
11
+ * rebuild them, and a token can only *move* if the frame after the edit still
12
+ * knows it is the same token.
13
+ */
14
+ /**
15
+ * How a `Code` node decides which token became which across an edit. Given
16
+ * the old and new tokenized structures, return the carried-over structure
17
+ * plus the added/removed/renamed-line bookkeeping a transition needs.
18
+ * `diffCode` below is the default implementation (exported as
19
+ * `defaultCodeDiff`) — pass a `diffStrategy` of this shape to `<Code>` to
20
+ * replace it with your own.
21
+ */
22
+ export type CodeDiffStrategy = (from: IdLine[], to: IdLine[]) => CodeEdit;
23
+
24
+ export interface CodeEdit {
25
+ /** The new structure. Ids are rewritten in place onto the tokens handed in. */
26
+ lines: IdLine[];
27
+ /** Ids present only in the old structure — these fade out. */
28
+ removedIds: Set<number>;
29
+ /** Ids present only in the new structure — these fade in. */
30
+ addedIds: Set<number>;
31
+ /** Ids of {@link lines} that did not exist before — these enter as a whole row. */
32
+ newLineIds: Set<number>;
33
+ /** Previous colour of a surviving token whose highlighting changed, for the cross-fade. */
34
+ fromColorById: Map<number, string | undefined>;
35
+ }
36
+
37
+ /**
38
+ * How similar two lines must be before they are treated as *one line that was
39
+ * edited* rather than as one line deleted and another inserted.
40
+ *
41
+ * Below this, a row's tokens have so little in common that carrying ids across
42
+ * would animate glyphs sliding between unrelated words. Above it, the edit reads
43
+ * the way it was made.
44
+ */
45
+ const LINE_MATCH_FLOOR = 0.45;
46
+
47
+ /**
48
+ * The most line pairs one run of changed lines is scored against each other, and
49
+ * the most across a whole edit.
50
+ *
51
+ * Scoring every pair is quadratic: with 50-character lines it took 31 ms at 200
52
+ * lines and 741 ms at 1,000, paid again for each edit command when it is built.
53
+ * So the lines are first paired where they cannot be in doubt (see
54
+ * {@link alignLines}), and only a run left between two such anchors is scored.
55
+ * A run past the first limit, or what is left of the second, is not compared: its
56
+ * lines leave and enter as whole rows. That is the budget, and an edit that stays
57
+ * inside it is diffed exactly as before.
58
+ */
59
+ export const LINE_ALIGN_CELLS = 40_000;
60
+ export const EDIT_ALIGN_CELLS = 160_000;
61
+
62
+ /** Weight a token carries when judging similarity: its visible characters. */
63
+ function tokenWeight(tok: IdToken): number {
64
+ return tok.content.trim().length;
65
+ }
66
+
67
+ function lineWeight(line: IdLine): number {
68
+ let sum = 0;
69
+ for (const tok of line.tokens) sum += tokenWeight(tok);
70
+ return sum;
71
+ }
72
+
73
+ /**
74
+ * Dice coefficient over the two lines' token multisets, weighted by visible
75
+ * characters so `variable` counts for far more than a run of spaces.
76
+ *
77
+ * Two blank lines are identical (1); a blank line against a non-blank one shares
78
+ * nothing (0), which keeps the blank rows a reformat introduces from anchoring
79
+ * themselves onto real code.
80
+ */
81
+ function similarity(a: IdLine, b: IdLine): number {
82
+ const wa = lineWeight(a);
83
+ const wb = lineWeight(b);
84
+ if (wa === 0 && wb === 0) return 1;
85
+ if (wa === 0 || wb === 0) return 0;
86
+
87
+ const bag = new Map<string, number>();
88
+ for (const tok of a.tokens) {
89
+ if (tokenWeight(tok) === 0) continue;
90
+ bag.set(tok.content, (bag.get(tok.content) ?? 0) + 1);
91
+ }
92
+ let common = 0;
93
+ for (const tok of b.tokens) {
94
+ const w = tokenWeight(tok);
95
+ if (w === 0) continue;
96
+ const left = bag.get(tok.content) ?? 0;
97
+ if (left > 0) {
98
+ bag.set(tok.content, left - 1);
99
+ common += w;
100
+ }
101
+ }
102
+ return (2 * common) / (wa + wb);
103
+ }
104
+
105
+ /**
106
+ * Align two sequences by maximising total `score`, skipping is free.
107
+ *
108
+ * A plain LCS would only ever pair *identical* elements, which is the wrong tool
109
+ * twice over: two lines that differ by one word are the same line, and two
110
+ * tokens that differ only in colour are the same token. Scoring the pairing and
111
+ * letting anything that scores ≤ 0 fall out expresses that directly, and because
112
+ * skips cost nothing the alignment stays in order.
113
+ *
114
+ * Returns the matched index pairs, in order.
115
+ */
116
+ function align(
117
+ aLength: number,
118
+ bLength: number,
119
+ score: (i: number, j: number) => number,
120
+ ): Array<[number, number]> {
121
+ // dp[i][j] = best score aligning a[0..i) with b[0..j).
122
+ const width = bLength + 1;
123
+ const dp = new Float64Array((aLength + 1) * width);
124
+ const pairScore = new Float64Array(aLength * bLength);
125
+
126
+ for (let i = 1; i <= aLength; i++) {
127
+ for (let j = 1; j <= bLength; j++) {
128
+ const s = score(i - 1, j - 1);
129
+ pairScore[(i - 1) * bLength + (j - 1)] = s;
130
+ const skipA = dp[(i - 1) * width + j];
131
+ const skipB = dp[i * width + (j - 1)];
132
+ let best = skipA > skipB ? skipA : skipB;
133
+ if (s > 0) {
134
+ const paired = dp[(i - 1) * width + (j - 1)] + s;
135
+ if (paired > best) best = paired;
136
+ }
137
+ dp[i * width + j] = best;
138
+ }
139
+ }
140
+
141
+ const pairs: Array<[number, number]> = [];
142
+ let i = aLength;
143
+ let j = bLength;
144
+ while (i > 0 && j > 0) {
145
+ const s = pairScore[(i - 1) * bLength + (j - 1)];
146
+ if (s > 0 && dp[i * width + j] === dp[(i - 1) * width + (j - 1)] + s) {
147
+ pairs.push([i - 1, j - 1]);
148
+ i--; j--;
149
+ } else if (dp[(i - 1) * width + j] >= dp[i * width + (j - 1)]) {
150
+ i--;
151
+ } else {
152
+ j--;
153
+ }
154
+ }
155
+ pairs.reverse();
156
+ return pairs;
157
+ }
158
+
159
+ /**
160
+ * Match the tokens of one line against another's.
161
+ *
162
+ * Scored by visible characters rather than token count, so an alignment that
163
+ * keeps `items.reduce` together beats one that keeps four more spaces. A token
164
+ * pairs only when its content is unchanged — a changed word is a different word,
165
+ * and should be seen to be replaced.
166
+ */
167
+ function alignTokens(from: IdToken[], to: IdToken[]): Array<[number, number]> {
168
+ return align(from.length, to.length, (i, j) => {
169
+ if (from[i].content !== to[j].content) return 0;
170
+ // Whitespace still pairs (it holds indentation together), just weakly
171
+ // enough that it never outranks real content.
172
+ return tokenWeight(to[j]) || 0.25;
173
+ });
174
+ }
175
+
176
+ function sameTokens(a: IdLine, b: IdLine): boolean {
177
+ if (a.tokens.length !== b.tokens.length) return false;
178
+ for (let k = 0; k < a.tokens.length; k++) if (a.tokens[k].content !== b.tokens[k].content) return false;
179
+ return true;
180
+ }
181
+
182
+ /** A line's text, token boundaries included, for telling identical lines apart from similar ones. */
183
+ function signature(line: IdLine): string {
184
+ let out = "";
185
+ for (const tok of line.tokens) out += tok.content + "\u0001";
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * Pair the lines of two listings, in order.
191
+ *
192
+ * Lines that are the same at the start and at the end are paired outright. In
193
+ * what is left, a line that occurs exactly once on each side is an anchor, and
194
+ * the longest run of anchors that keeps their order splits the rest into runs of
195
+ * changed lines, each scored against itself within the budget (see
196
+ * {@link LINE_ALIGN_CELLS}). A one-line edit to a long listing therefore never
197
+ * builds a table bigger than the lines around it.
198
+ */
199
+ function alignLines(from: IdLine[], to: IdLine[]): Array<[number, number]> {
200
+ const fromSig = from.map(signature);
201
+ const toSig = to.map(signature);
202
+ const pairs: Array<[number, number]> = [];
203
+
204
+ let head = 0;
205
+ while (head < from.length && head < to.length && fromSig[head] === toSig[head]) {
206
+ pairs.push([head, head]);
207
+ head++;
208
+ }
209
+ let tail = 0;
210
+ while (
211
+ tail < from.length - head && tail < to.length - head
212
+ && fromSig[from.length - 1 - tail] === toSig[to.length - 1 - tail]
213
+ ) tail++;
214
+
215
+ const anchors = anchorLines(fromSig, toSig, head, from.length - tail, head, to.length - tail);
216
+ let budget = EDIT_ALIGN_CELLS;
217
+ let fi = head;
218
+ let ti = head;
219
+ for (const [af, at] of [...anchors, [from.length - tail, to.length - tail] as [number, number]]) {
220
+ const rows = af - fi;
221
+ const cols = at - ti;
222
+ const cells = rows * cols;
223
+ if (cells > 0 && cells <= LINE_ALIGN_CELLS && cells <= budget) {
224
+ budget -= cells;
225
+ for (const [i, j] of align(rows, cols, (a, b) => similarity(from[fi + a], to[ti + b]) - LINE_MATCH_FLOOR)) {
226
+ pairs.push([fi + i, ti + j]);
227
+ }
228
+ }
229
+ if (af < from.length - tail) pairs.push([af, at]);
230
+ fi = af + 1;
231
+ ti = at + 1;
232
+ }
233
+ for (let k = tail; k > 0; k--) pairs.push([from.length - k, to.length - k]);
234
+ return pairs;
235
+ }
236
+
237
+ /**
238
+ * The lines that occur once in `from[fa, fb)` and once in `to[ta, tb)`, reduced to
239
+ * the longest subsequence that is increasing on both sides (patience sorting).
240
+ * Blank lines are never anchors: they say nothing about where they belong.
241
+ */
242
+ function anchorLines(
243
+ fromSig: string[],
244
+ toSig: string[],
245
+ fa: number,
246
+ fb: number,
247
+ ta: number,
248
+ tb: number,
249
+ ): Array<[number, number]> {
250
+ const fromAt = new Map<string, number>();
251
+ const seen = new Set<string>();
252
+ for (let i = fa; i < fb; i++) {
253
+ const sig = fromSig[i];
254
+ if (seen.has(sig)) fromAt.delete(sig);
255
+ else fromAt.set(sig, i);
256
+ seen.add(sig);
257
+ }
258
+ const toAt = new Map<string, number>();
259
+ const seenTo = new Set<string>();
260
+ for (let j = ta; j < tb; j++) {
261
+ const sig = toSig[j];
262
+ if (seenTo.has(sig)) toAt.delete(sig);
263
+ else toAt.set(sig, j);
264
+ seenTo.add(sig);
265
+ }
266
+
267
+ // In `to` order, each candidate carries its `from` position.
268
+ const candidates: Array<[number, number]> = [];
269
+ for (let j = ta; j < tb; j++) {
270
+ const i = fromAt.get(toSig[j]);
271
+ if (i !== undefined && toAt.get(toSig[j]) === j && toSig[j].replaceAll("\u0001", "").trim() !== "") {
272
+ candidates.push([i, j]);
273
+ }
274
+ }
275
+
276
+ const tails: number[] = [];
277
+ const previous: number[] = new Array(candidates.length).fill(-1);
278
+ for (let c = 0; c < candidates.length; c++) {
279
+ let lo = 0;
280
+ let hi = tails.length;
281
+ while (lo < hi) {
282
+ const mid = (lo + hi) >> 1;
283
+ if (candidates[tails[mid]][0] < candidates[c][0]) lo = mid + 1;
284
+ else hi = mid;
285
+ }
286
+ if (lo > 0) previous[c] = tails[lo - 1];
287
+ tails[lo] = c;
288
+ }
289
+ const chain: Array<[number, number]> = [];
290
+ for (let c = tails.length > 0 ? tails[tails.length - 1] : -1; c >= 0; c = previous[c]) chain.push(candidates[c]);
291
+ return chain.reverse();
292
+ }
293
+
294
+ /**
295
+ * Carry `from`'s identities onto `to`, and report what changed.
296
+ *
297
+ * `to` is freshly tokenized and owns ids nobody has seen yet, so rewriting them
298
+ * in place is safe and is what makes the transition cheap: after this, the two
299
+ * structures share an id for every token that survived the edit.
300
+ */
301
+ export function diffCode(from: IdLine[], to: IdLine[]): CodeEdit {
302
+ const removedIds = new Set<number>();
303
+ const addedIds = new Set<number>();
304
+ const newLineIds = new Set<number>();
305
+ const fromColorById = new Map<number, string | undefined>();
306
+
307
+ const linePairs = alignLines(from, to);
308
+
309
+ const pairedTo = new Set<number>();
310
+ const pairedFrom = new Set<number>();
311
+
312
+ for (const [fi, ti] of linePairs) {
313
+ pairedFrom.add(fi);
314
+ pairedTo.add(ti);
315
+ const fromLine = from[fi];
316
+ const toLine = to[ti];
317
+ toLine.id = fromLine.id;
318
+
319
+ const matched = sameTokens(fromLine, toLine)
320
+ ? fromLine.tokens.map((_, k): [number, number] => [k, k])
321
+ : alignTokens(fromLine.tokens, toLine.tokens);
322
+ const keptFrom = new Set<number>();
323
+ const keptTo = new Set<number>();
324
+ for (const [a, b] of matched) {
325
+ keptFrom.add(a);
326
+ keptTo.add(b);
327
+ const oldTok = fromLine.tokens[a];
328
+ const newTok = toLine.tokens[b];
329
+ newTok.id = oldTok.id;
330
+ if (oldTok.color !== newTok.color) fromColorById.set(oldTok.id, oldTok.color);
331
+ }
332
+ for (let a = 0; a < fromLine.tokens.length; a++) {
333
+ if (!keptFrom.has(a)) removedIds.add(fromLine.tokens[a].id);
334
+ }
335
+ for (let b = 0; b < toLine.tokens.length; b++) {
336
+ if (!keptTo.has(b)) addedIds.add(toLine.tokens[b].id);
337
+ }
338
+ }
339
+
340
+ for (let i = 0; i < from.length; i++) {
341
+ if (pairedFrom.has(i)) continue;
342
+ for (const tok of from[i].tokens) removedIds.add(tok.id);
343
+ }
344
+ for (let j = 0; j < to.length; j++) {
345
+ if (pairedTo.has(j)) continue;
346
+ newLineIds.add(to[j].id);
347
+ for (const tok of to[j].tokens) addedIds.add(tok.id);
348
+ }
349
+
350
+ return { lines: to, removedIds, addedIds, newLineIds, fromColorById };
351
+ }