@type-editor/changeset 0.0.2

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/Change.ts ADDED
@@ -0,0 +1,456 @@
1
+ import {Span} from './Span';
2
+ import type { ChangeJSON } from './types/ChangeJSON';
3
+
4
+
5
+ /**
6
+ * Result of merging overlapping changes.
7
+ *
8
+ * @template Data - The type of metadata in the changes.
9
+ */
10
+ interface MergeResult<Data> {
11
+ /** The merged change, or null if no actual change occurred. */
12
+ change: Change<Data> | null;
13
+ /** The next change from the X changeset to process. */
14
+ nextX: Change<Data> | null;
15
+ /** The next change from the Y changeset to process. */
16
+ nextY: Change<Data> | null;
17
+ /** The updated index in the X changeset. */
18
+ indexX: number;
19
+ /** The updated index in the Y changeset. */
20
+ indexY: number;
21
+ }
22
+
23
+ /**
24
+ * Represents a change between two document versions with metadata.
25
+ *
26
+ * A Change tracks a replaced range in the document, including both what was
27
+ * deleted from the old version and what was inserted in the new version.
28
+ * It uses two coordinate systems:
29
+ * - A coordinates: positions in the old document
30
+ * - B coordinates: positions in the new document
31
+ *
32
+ * @template Data - The type of metadata associated with the changed content.
33
+ */
34
+ export class Change<Data = any> {
35
+
36
+ private readonly _fromA: number;
37
+ private readonly _toA: number;
38
+ private readonly _fromB: number;
39
+ private readonly _toB: number;
40
+ private readonly _deleted: ReadonlyArray<Span<Data>>;
41
+ private readonly _inserted: ReadonlyArray<Span<Data>>;
42
+
43
+ /**
44
+ * Creates a new Change representing a document modification.
45
+ *
46
+ * @param fromA - The start position of the range in the old document (A coordinates).
47
+ * @param toA - The end position of the range in the old document (A coordinates).
48
+ * @param fromB - The start position of the range in the new document (B coordinates).
49
+ * @param toB - The end position of the range in the new document (B coordinates).
50
+ * @param deleted - Metadata spans for the deleted content. The total length of these
51
+ * spans must equal `toA - fromA`.
52
+ * @param inserted - Metadata spans for the inserted content. The total length of these
53
+ * spans must equal `toB - fromB`.
54
+ */
55
+ constructor(fromA: number,
56
+ toA: number,
57
+ fromB: number,
58
+ toB: number,
59
+ deleted: ReadonlyArray<Span<Data>>,
60
+ inserted: ReadonlyArray<Span<Data>>) {
61
+ this._fromA = fromA;
62
+ this._toA = toA;
63
+ this._fromB = fromB;
64
+ this._toB = toB;
65
+ this._deleted = deleted;
66
+ this._inserted = inserted;
67
+ }
68
+
69
+ /** The start position in the old document (A coordinates). */
70
+ get fromA(): number {
71
+ return this._fromA;
72
+ }
73
+
74
+ /** The end position in the old document (A coordinates). */
75
+ get toA(): number {
76
+ return this._toA;
77
+ }
78
+
79
+ /** The start position in the new document (B coordinates). */
80
+ get fromB(): number {
81
+ return this._fromB;
82
+ }
83
+
84
+ /** The end position in the new document (B coordinates). */
85
+ get toB(): number {
86
+ return this._toB;
87
+ }
88
+
89
+ /** The spans of deleted content with associated metadata. */
90
+ get deleted(): ReadonlyArray<Span<Data>> {
91
+ return this._deleted;
92
+ }
93
+
94
+ /** The spans of inserted content with associated metadata. */
95
+ get inserted(): ReadonlyArray<Span<Data>> {
96
+ return this._inserted;
97
+ }
98
+
99
+ /** The length of the deleted range in the old document. */
100
+ get lenA(): number {
101
+ return this._toA - this._fromA;
102
+ }
103
+
104
+ /** The length of the inserted range in the new document. */
105
+ get lenB(): number {
106
+ return this._toB - this._fromB;
107
+ }
108
+
109
+ /**
110
+ * Merges two changesets into a single changeset.
111
+ *
112
+ * This combines two sequential changesets where the end document of the first
113
+ * changeset is the start document of the second. The result is a single changeset
114
+ * spanning from the start of the first to the end of the second.
115
+ *
116
+ * The merge operates by synchronizing over a "middle" coordinate system:
117
+ * - For the first changeset (x): the B coordinates represent the middle document
118
+ * - For the second changeset (y): the A coordinates represent the middle document
119
+ *
120
+ * @template Data - The type of metadata in the changes.
121
+ * @param x - The first changeset.
122
+ * @param y - The second changeset applied after x.
123
+ * @param combine - Function to combine metadata when spans need to be merged.
124
+ * @returns A single changeset representing both transformations.
125
+ */
126
+ public static merge<Data>(x: ReadonlyArray<Change<Data>>,
127
+ y: ReadonlyArray<Change<Data>>,
128
+ combine: (dataA: Data, dataB: Data) => Data): ReadonlyArray<Change<Data>> {
129
+ // Fast paths for empty changesets
130
+ if (x.length === 0) {
131
+ return y;
132
+ }
133
+ if (y.length === 0) {
134
+ return x;
135
+ }
136
+
137
+ const result: Array<Change<Data>> = [];
138
+
139
+ // Iterate over both sets in parallel, using the middle coordinate
140
+ // system (B in x, A in y) to synchronize.
141
+ let currentX: Change<Data> | null = x[0];
142
+ let currentY: Change<Data> | null = y[0];
143
+ let indexX = 0;
144
+ let indexY = 0;
145
+
146
+ // Track cumulative offsets incrementally to avoid O(n²) recalculation
147
+ let cumulativeXOffset = 0;
148
+ let cumulativeYOffset = 0;
149
+
150
+ while (currentX || currentY) {
151
+ if (!currentX && !currentY) {
152
+ break;
153
+ }
154
+ // currentX entirely before currentY in the middle coordinate system
155
+ else if (currentX && (!currentY || currentX.toB < currentY._fromA)) {
156
+ result.push(this.adjustChangeForYOffset(currentX, cumulativeYOffset));
157
+ cumulativeXOffset += currentX.lenB - currentX.lenA;
158
+ currentX = ++indexX < x.length ? x[indexX] : null;
159
+ }
160
+ // currentY entirely before currentX in the middle coordinate system
161
+ else if (currentY && (!currentX || currentY.toA < currentX.fromB)) {
162
+ result.push(this.adjustChangeForXOffset(currentY, cumulativeXOffset));
163
+ cumulativeYOffset += currentY.lenB - currentY.lenA;
164
+ currentY = ++indexY < y.length ? y[indexY] : null;
165
+ }
166
+ // Changes overlap - need to merge them
167
+ else {
168
+ const mergeResult: MergeResult<Data> = this.mergeOverlappingChanges(
169
+ x, y, currentX, currentY, indexX, indexY,
170
+ cumulativeXOffset, cumulativeYOffset, combine
171
+ );
172
+
173
+ if (mergeResult.change) {
174
+ result.push(mergeResult.change);
175
+ }
176
+
177
+ // Update cumulative offsets for all processed changes
178
+ for (let i = indexX; i < mergeResult.indexX; i++) {
179
+ cumulativeXOffset += x[i].lenB - x[i].lenA;
180
+ }
181
+ for (let i = indexY; i < mergeResult.indexY; i++) {
182
+ cumulativeYOffset += y[i].lenB - y[i].lenA;
183
+ }
184
+
185
+ currentX = mergeResult.nextX;
186
+ currentY = mergeResult.nextY;
187
+ indexX = mergeResult.indexX;
188
+ indexY = mergeResult.indexY;
189
+ }
190
+ }
191
+
192
+ return result;
193
+ }
194
+
195
+ /**
196
+ * Deserializes a Change from its JSON representation.
197
+ *
198
+ * @template Data - The type of metadata in the change.
199
+ * @param json - The JSON object representing the change.
200
+ * @returns A new Change instance reconstructed from the JSON data.
201
+ */
202
+ public static fromJSON<Data>(json: ChangeJSON<Data>): Change<Data> {
203
+ return new Change(
204
+ json.fromA,
205
+ json.toA,
206
+ json.fromB,
207
+ json.toB,
208
+ json.deleted.map((deleted: { length: number; data: Data }): Span<Data> => new Span(deleted.length, deleted.data)),
209
+ json.inserted.map((inserted: { length: number; data: Data }): Span<Data> => new Span(inserted.length, inserted.data))
210
+ );
211
+ }
212
+
213
+ /**
214
+ * Adjusts a change from the X changeset by applying a Y offset.
215
+ *
216
+ * @param change - The change to adjust.
217
+ * @param offset - The offset to apply to B coordinates.
218
+ * @returns A new change with adjusted coordinates, or the original if offset is 0.
219
+ */
220
+ private static adjustChangeForYOffset<Data>(change: Change<Data>,
221
+ offset: number): Change<Data> {
222
+ if (offset === 0) {
223
+ return change;
224
+ }
225
+
226
+ return new Change(
227
+ change.fromA,
228
+ change.toA,
229
+ change.fromB + offset,
230
+ change.toB + offset,
231
+ change.deleted,
232
+ change.inserted
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Adjusts a change from the Y changeset by applying an X offset.
238
+ *
239
+ * @param change - The change to adjust.
240
+ * @param offset - The offset to apply to A coordinates.
241
+ * @returns A new change with adjusted coordinates, or the original if offset is 0.
242
+ */
243
+ private static adjustChangeForXOffset<Data>(change: Change<Data>,
244
+ offset: number): Change<Data> {
245
+ if (offset === 0) {
246
+ return change;
247
+ }
248
+
249
+ return new Change(
250
+ change.fromA - offset,
251
+ change.toA - offset,
252
+ change.fromB,
253
+ change.toB,
254
+ change.deleted,
255
+ change.inserted
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Merges overlapping changes from both changesets.
261
+ *
262
+ * When changes from X and Y overlap in the middle coordinate system, they need
263
+ * to be merged. The rules are:
264
+ * - Deletions from X and insertions from Y are kept
265
+ * - Areas covered by X but not Y are insertions from X
266
+ * - Areas covered by Y but not X are deletions from Y
267
+ *
268
+ * @param x - The first changeset array.
269
+ * @param y - The second changeset array.
270
+ * @param currentX - Current change from X.
271
+ * @param currentY - Current change from Y.
272
+ * @param indexX - Current index in X.
273
+ * @param indexY - Current index in Y.
274
+ * @param xOffset - Pre-calculated cumulative offset from previous X changes.
275
+ * @param yOffset - Pre-calculated cumulative offset from previous Y changes.
276
+ * @param combine - Function to combine metadata.
277
+ * @returns The merge result containing the merged change and updated state.
278
+ */
279
+ private static mergeOverlappingChanges<Data>(x: ReadonlyArray<Change<Data>>,
280
+ y: ReadonlyArray<Change<Data>>,
281
+ currentX: Change<Data> | null,
282
+ currentY: Change<Data> | null,
283
+ indexX: number,
284
+ indexY: number,
285
+ xOffset: number,
286
+ yOffset: number,
287
+ combine: (dataA: Data, dataB: Data) => Data): MergeResult<Data> {
288
+ // At this point, we're guaranteed that at least one of currentX or currentY
289
+ // is not null and they overlap. We assert this for type safety.
290
+ if (!currentX || !currentY) {
291
+ throw new Error('mergeOverlappingChanges called with null change');
292
+ }
293
+
294
+ // Start position in the middle coordinate system
295
+ let position: number = Math.min(currentX.fromB, currentY.fromA);
296
+
297
+ // Calculate the merged change's boundaries using pre-calculated offsets
298
+ const fromA: number = Math.min(currentX.fromA, currentY.fromA - xOffset);
299
+ let toA: number = fromA;
300
+ const fromB: number = Math.min(currentY.fromB, currentX.fromB + yOffset);
301
+ let toB: number = fromB;
302
+
303
+ let deleted: ReadonlyArray<Span> = Span.none;
304
+ let inserted: ReadonlyArray<Span> = Span.none;
305
+
306
+ // Track whether we've entered each change to avoid double-processing
307
+ let enteredX = false;
308
+ let enteredY = false;
309
+
310
+ // Mutable references to current changes
311
+ let workingX: Change<Data> | null = currentX;
312
+ let workingY: Change<Data> | null = currentY;
313
+
314
+ // Process all overlapping changes
315
+ while (true) {
316
+ const nextPositionX: number = this.getNextBoundary(workingX, position, 'fromB', 'toB');
317
+ const nextPositionY: number = this.getNextBoundary(workingY, position, 'fromA', 'toA');
318
+ const nextPosition: number = Math.min(nextPositionX, nextPositionY);
319
+
320
+ const inX: boolean = workingX && position >= workingX.fromB;
321
+ const inY: boolean = workingY && position >= workingY.fromA;
322
+
323
+ if (!inX && !inY) {
324
+ break;
325
+ }
326
+
327
+
328
+ // Process X change at entry point
329
+ if (inX && position === workingX?.fromB && !enteredX) {
330
+ deleted = Span.join(deleted, workingX.deleted, combine);
331
+ toA += workingX.lenA;
332
+ enteredX = true;
333
+ }
334
+
335
+ // Process X insertions where Y doesn't cover
336
+ if (inX && !inY && workingX) {
337
+ const slicedInserted: ReadonlyArray<Span<Data>> = Span.slice(
338
+ workingX.inserted,
339
+ position - workingX.fromB,
340
+ nextPosition - workingX.fromB
341
+ );
342
+
343
+ inserted = Span.join(inserted, slicedInserted, combine);
344
+ toB += nextPosition - position;
345
+ }
346
+
347
+ // Process Y change at entry point
348
+ if (inY && position === workingY?.fromA && !enteredY) {
349
+ inserted = Span.join(inserted, workingY.inserted, combine);
350
+ toB += workingY.lenB;
351
+ enteredY = true;
352
+ }
353
+
354
+ // Process Y deletions where X doesn't cover
355
+ if (inY && !inX && workingY) {
356
+ const slicedDeleted: ReadonlyArray<Span<Data>> = Span.slice(
357
+ workingY.deleted,
358
+ position - workingY.fromA,
359
+ nextPosition - workingY.fromA
360
+ );
361
+
362
+ deleted = Span.join(deleted, slicedDeleted, combine);
363
+ toA += nextPosition - position;
364
+ }
365
+
366
+ // Advance X if we've finished this change
367
+ if (inX && nextPosition === workingX?.toB) {
368
+ indexX++;
369
+ workingX = indexX < x.length ? x[indexX] : null;
370
+ enteredX = false;
371
+ }
372
+
373
+ // Advance Y if we've finished this change
374
+ if (inY && nextPosition === workingY?.toA) {
375
+ indexY++;
376
+ workingY = indexY < y.length ? y[indexY] : null;
377
+ enteredY = false;
378
+ }
379
+
380
+ position = nextPosition;
381
+ }
382
+
383
+ // Only create a change if there's actual content
384
+ const change: Change = (fromA < toA || fromB < toB)
385
+ ? new Change(fromA, toA, fromB, toB, deleted, inserted)
386
+ : null;
387
+
388
+ return {change, nextX: workingX, nextY: workingY, indexX, indexY};
389
+ }
390
+
391
+ /**
392
+ * Determines the next boundary position for a change.
393
+ *
394
+ * @param change - The change to examine.
395
+ * @param currentPosition - The current position.
396
+ * @param fromKey - The property name for the start position.
397
+ * @param toKey - The property name for the end position.
398
+ * @returns The next boundary position (or a large number if no change).
399
+ */
400
+ private static getNextBoundary<Data>(change: Change<Data> | null,
401
+ currentPosition: number,
402
+ fromKey: 'fromA' | 'fromB',
403
+ toKey: 'toA' | 'toB'): number {
404
+ if (!change) {
405
+ return Number.MAX_SAFE_INTEGER;
406
+ }
407
+
408
+ return currentPosition >= change[fromKey] ? change[toKey] : change[fromKey];
409
+ }
410
+
411
+ /**
412
+ * Creates a sub-change by slicing ranges from both coordinate systems.
413
+ *
414
+ * This extracts a portion of this change, specified by ranges in both
415
+ * the A and B coordinate systems. If the slice covers the entire change,
416
+ * returns this instance to avoid allocation.
417
+ *
418
+ * @param startA - The start position in A coordinates (relative to this change).
419
+ * @param endA - The end position in A coordinates (relative to this change).
420
+ * @param startB - The start position in B coordinates (relative to this change).
421
+ * @param endB - The end position in B coordinates (relative to this change).
422
+ * @returns A new Change representing the specified slice, or this if unchanged.
423
+ */
424
+ public slice(startA: number,
425
+ endA: number,
426
+ startB: number,
427
+ endB: number): Change<Data> {
428
+ // If slicing the entire range, return this to avoid allocation
429
+ const coversFullRangeA: boolean = startA === 0 && endA === this.lenA;
430
+ const coversFullRangeB: boolean = startB === 0 && endB === this.lenB;
431
+
432
+ if (coversFullRangeA && coversFullRangeB) {
433
+ return this;
434
+ }
435
+
436
+ return new Change(
437
+ this._fromA + startA,
438
+ this._fromA + endA,
439
+ this._fromB + startB,
440
+ this._fromB + endB,
441
+ Span.slice(this._deleted, startA, endA),
442
+ Span.slice(this._inserted, startB, endB));
443
+ }
444
+
445
+ /**
446
+ * Serializes this Change to a JSON-compatible representation.
447
+ *
448
+ * Since the Change class structure matches the ChangeJSON interface,
449
+ * this method returns the instance itself.
450
+ *
451
+ * @returns A JSON representation of this change.
452
+ */
453
+ public toJSON(): ChangeJSON<Data> {
454
+ return this;
455
+ }
456
+ }