Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/test/common/model/annotations.test.ts
4779 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
8
import { AnnotatedString, AnnotationsUpdate, IAnnotation, IAnnotationUpdate } from '../../../common/model/tokens/annotations.js';
9
import { OffsetRange } from '../../../common/core/ranges/offsetRange.js';
10
import { StringEdit } from '../../../common/core/edits/stringEdit.js';
11
12
// ============================================================================
13
// Visual Annotation Test Infrastructure
14
// ============================================================================
15
// This infrastructure allows representing annotations visually using brackets:
16
// - '[id:text]' marks an annotation with the given id covering 'text'
17
// - Plain text represents unannotated content
18
//
19
// Example: "Lorem [1:ipsum] dolor [2:sit] amet" represents:
20
// - annotation "1" at offset 6-11 (content "ipsum")
21
// - annotation "2" at offset 18-21 (content "sit")
22
//
23
// For updates:
24
// - '[id:text]' sets an annotation
25
// - '<id:text>' deletes an annotation in that range
26
// ============================================================================
27
28
/**
29
* Parses a visual string representation into annotations.
30
* The visual string uses '[id:text]' to mark annotation boundaries.
31
* The id becomes the annotation value, and text is the annotated content.
32
*/
33
function parseVisualAnnotations(visual: string): { annotations: IAnnotation<string>[]; baseString: string } {
34
const annotations: IAnnotation<string>[] = [];
35
let baseString = '';
36
let i = 0;
37
38
while (i < visual.length) {
39
if (visual[i] === '[') {
40
// Find the colon and closing bracket
41
const colonIdx = visual.indexOf(':', i + 1);
42
const closeIdx = visual.indexOf(']', colonIdx + 1);
43
if (colonIdx === -1 || closeIdx === -1) {
44
throw new Error(`Invalid annotation format at position ${i}`);
45
}
46
const id = visual.substring(i + 1, colonIdx);
47
const text = visual.substring(colonIdx + 1, closeIdx);
48
const startOffset = baseString.length;
49
baseString += text;
50
annotations.push({ range: new OffsetRange(startOffset, baseString.length), annotation: id });
51
i = closeIdx + 1;
52
} else {
53
baseString += visual[i];
54
i++;
55
}
56
}
57
58
return { annotations, baseString };
59
}
60
61
/**
62
* Converts annotations to a visual string representation.
63
* Uses '[id:text]' to mark annotation boundaries.
64
*
65
* @param annotations - The annotations to visualize
66
* @param baseString - The base string content
67
*/
68
function toVisualString(
69
annotations: IAnnotation<string>[],
70
baseString: string
71
): string {
72
if (annotations.length === 0) {
73
return baseString;
74
}
75
76
// Sort annotations by start position
77
const sortedAnnotations = [...annotations].sort((a, b) => a.range.start - b.range.start);
78
79
// Build the visual representation
80
let result = '';
81
let pos = 0;
82
83
for (const ann of sortedAnnotations) {
84
// Add plain text before this annotation
85
result += baseString.substring(pos, ann.range.start);
86
// Add annotated content with id
87
const annotatedText = baseString.substring(ann.range.start, ann.range.endExclusive);
88
result += `[${ann.annotation}:${annotatedText}]`;
89
pos = ann.range.endExclusive;
90
}
91
92
// Add remaining text after last annotation
93
result += baseString.substring(pos);
94
95
return result;
96
}
97
98
/**
99
* Represents an AnnotatedString with its base string for visual testing.
100
*/
101
class VisualAnnotatedString {
102
constructor(
103
public readonly annotatedString: AnnotatedString<string>,
104
public baseString: string
105
) { }
106
107
setAnnotations(update: AnnotationsUpdate<string>): void {
108
this.annotatedString.setAnnotations(update);
109
}
110
111
applyEdit(edit: StringEdit): void {
112
this.annotatedString.applyEdit(edit);
113
this.baseString = edit.apply(this.baseString);
114
}
115
116
getAnnotationsIntersecting(range: OffsetRange): IAnnotation<string>[] {
117
return this.annotatedString.getAnnotationsIntersecting(range);
118
}
119
120
getAllAnnotations(): IAnnotation<string>[] {
121
return this.annotatedString.getAllAnnotations();
122
}
123
124
clone(): VisualAnnotatedString {
125
return new VisualAnnotatedString(this.annotatedString.clone() as AnnotatedString<string>, this.baseString);
126
}
127
}
128
129
/**
130
* Creates a VisualAnnotatedString from a visual representation.
131
*/
132
function fromVisual(visual: string): VisualAnnotatedString {
133
const { annotations, baseString } = parseVisualAnnotations(visual);
134
return new VisualAnnotatedString(new AnnotatedString<string>(annotations), baseString);
135
}
136
137
/**
138
* Converts a VisualAnnotatedString to a visual representation.
139
*/
140
function toVisual(vas: VisualAnnotatedString): string {
141
return toVisualString(vas.getAllAnnotations(), vas.baseString);
142
}
143
144
/**
145
* Parses visual update annotations, where:
146
* - '[id:text]' represents an annotation to set
147
* - '<id:text>' represents an annotation to delete (range is tracked but annotation is undefined)
148
*/
149
function parseVisualUpdate(visual: string): { updates: IAnnotationUpdate<string>[]; baseString: string } {
150
const updates: IAnnotationUpdate<string>[] = [];
151
let baseString = '';
152
let i = 0;
153
154
while (i < visual.length) {
155
if (visual[i] === '[') {
156
// Set annotation: [id:text]
157
const colonIdx = visual.indexOf(':', i + 1);
158
const closeIdx = visual.indexOf(']', colonIdx + 1);
159
if (colonIdx === -1 || closeIdx === -1) {
160
throw new Error(`Invalid annotation format at position ${i}`);
161
}
162
const id = visual.substring(i + 1, colonIdx);
163
const text = visual.substring(colonIdx + 1, closeIdx);
164
const startOffset = baseString.length;
165
baseString += text;
166
updates.push({ range: new OffsetRange(startOffset, baseString.length), annotation: id });
167
i = closeIdx + 1;
168
} else if (visual[i] === '<') {
169
// Delete annotation: <id:text>
170
const colonIdx = visual.indexOf(':', i + 1);
171
const closeIdx = visual.indexOf('>', colonIdx + 1);
172
if (colonIdx === -1 || closeIdx === -1) {
173
throw new Error(`Invalid delete format at position ${i}`);
174
}
175
const text = visual.substring(colonIdx + 1, closeIdx);
176
const startOffset = baseString.length;
177
baseString += text;
178
updates.push({ range: new OffsetRange(startOffset, baseString.length), annotation: undefined });
179
i = closeIdx + 1;
180
} else {
181
baseString += visual[i];
182
i++;
183
}
184
}
185
186
return { updates, baseString };
187
}
188
189
/**
190
* Creates an AnnotationsUpdate from a visual representation.
191
*/
192
function updateFromVisual(...visuals: string[]): AnnotationsUpdate<string> {
193
const updates: IAnnotationUpdate<string>[] = [];
194
195
for (const visual of visuals) {
196
const { updates: parsedUpdates } = parseVisualUpdate(visual);
197
updates.push(...parsedUpdates);
198
}
199
200
return AnnotationsUpdate.create(updates);
201
}
202
203
/**
204
* Helper to create a StringEdit from visual notation.
205
* Uses a pattern matching approach where:
206
* - 'd' marks positions to delete
207
* - 'i:text:' inserts 'text' at the marked position
208
*
209
* Simpler approach: just use offset-based helpers
210
*/
211
function editDelete(start: number, end: number): StringEdit {
212
return StringEdit.replace(new OffsetRange(start, end), '');
213
}
214
215
function editInsert(pos: number, text: string): StringEdit {
216
return StringEdit.insert(pos, text);
217
}
218
219
function editReplace(start: number, end: number, text: string): StringEdit {
220
return StringEdit.replace(new OffsetRange(start, end), text);
221
}
222
223
/**
224
* Asserts that a VisualAnnotatedString matches the expected visual representation.
225
* Only compares annotations, not the base string (since setAnnotations doesn't change the base string).
226
*/
227
function assertVisual(vas: VisualAnnotatedString, expectedVisual: string): void {
228
const actual = toVisual(vas);
229
const { annotations: expectedAnnotations } = parseVisualAnnotations(expectedVisual);
230
const actualAnnotations = vas.getAllAnnotations();
231
232
// Compare annotations for better error messages
233
if (actualAnnotations.length !== expectedAnnotations.length) {
234
assert.fail(
235
`Annotation count mismatch.\n` +
236
` Expected: ${expectedVisual}\n` +
237
` Actual: ${actual}\n` +
238
` Expected ${expectedAnnotations.length} annotations, got ${actualAnnotations.length}`
239
);
240
}
241
242
for (let i = 0; i < actualAnnotations.length; i++) {
243
const expected = expectedAnnotations[i];
244
const actualAnn = actualAnnotations[i];
245
if (actualAnn.range.start !== expected.range.start || actualAnn.range.endExclusive !== expected.range.endExclusive) {
246
assert.fail(
247
`Annotation ${i} range mismatch.\n` +
248
` Expected: (${expected.range.start}, ${expected.range.endExclusive})\n` +
249
` Actual: (${actualAnn.range.start}, ${actualAnn.range.endExclusive})\n` +
250
` Expected visual: ${expectedVisual}\n` +
251
` Actual visual: ${actual}`
252
);
253
}
254
if (actualAnn.annotation !== expected.annotation) {
255
assert.fail(
256
`Annotation ${i} value mismatch.\n` +
257
` Expected: "${expected.annotation}"\n` +
258
` Actual: "${actualAnn.annotation}"`
259
);
260
}
261
}
262
}
263
264
/**
265
* Helper to visualize the effect of an edit on annotations.
266
* Returns both before and after states as visual strings.
267
*/
268
function visualizeEdit(
269
beforeAnnotations: string,
270
edit: StringEdit
271
): { before: string; after: string } {
272
const vas = fromVisual(beforeAnnotations);
273
const before = toVisual(vas);
274
275
vas.applyEdit(edit);
276
277
const after = toVisual(vas);
278
return { before, after };
279
}
280
281
// ============================================================================
282
// Visual Annotations Test Suite
283
// ============================================================================
284
// These tests use a visual representation for better readability:
285
// - '[id:text]' marks annotated regions with id and content
286
// - Plain text represents unannotated content
287
// - '<id:text>' marks regions to delete (in updates)
288
//
289
// Example: "Lorem [1:ipsum] dolor [2:sit] amet" represents two annotations:
290
// "1" at (6,11) covering "ipsum", "2" at (18,21) covering "sit"
291
// ============================================================================
292
293
suite('Annotations Suite', () => {
294
295
ensureNoDisposablesAreLeakedInTestSuite();
296
297
test('setAnnotations 1', () => {
298
const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');
299
vas.setAnnotations(updateFromVisual('[4:Lorem i]'));
300
assertVisual(vas, '[4:Lorem i]psum [2:dolor] sit [3:amet]');
301
vas.setAnnotations(updateFromVisual('Lorem ip[5:s]'));
302
assertVisual(vas, '[4:Lorem i]p[5:s]um [2:dolor] sit [3:amet]');
303
});
304
305
test('setAnnotations 2', () => {
306
const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');
307
vas.setAnnotations(updateFromVisual(
308
'L<_:orem ipsum d>',
309
'[4:Lorem ]'
310
));
311
assertVisual(vas, '[4:Lorem ]ipsum dolor sit [3:amet]');
312
vas.setAnnotations(updateFromVisual(
313
'Lorem <_:ipsum dolor sit amet>',
314
'[5:Lor]'
315
));
316
assertVisual(vas, '[5:Lor]em ipsum dolor sit amet');
317
vas.setAnnotations(updateFromVisual('L[6:or]'));
318
assertVisual(vas, 'L[6:or]em ipsum dolor sit amet');
319
});
320
321
test('setAnnotations 3', () => {
322
const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');
323
vas.setAnnotations(updateFromVisual('Lore[4:m ipsum dolor ]'));
324
assertVisual(vas, 'Lore[4:m ipsum dolor ]sit [3:amet]');
325
vas.setAnnotations(updateFromVisual('Lorem ipsum dolor sit [5:a]'));
326
assertVisual(vas, 'Lore[4:m ipsum dolor ]sit [5:a]met');
327
});
328
329
test('getAnnotationsIntersecting 1', () => {
330
const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');
331
const result1 = vas.getAnnotationsIntersecting(new OffsetRange(0, 13));
332
assert.strictEqual(result1.length, 2);
333
assert.deepStrictEqual(result1.map(a => a.annotation), ['1', '2']);
334
const result2 = vas.getAnnotationsIntersecting(new OffsetRange(0, 22));
335
assert.strictEqual(result2.length, 3);
336
assert.deepStrictEqual(result2.map(a => a.annotation), ['1', '2', '3']);
337
});
338
339
test('getAnnotationsIntersecting 2', () => {
340
const vas = fromVisual('[1:Lorem] [2:i]p[3:s]');
341
342
const result1 = vas.getAnnotationsIntersecting(new OffsetRange(5, 7));
343
assert.strictEqual(result1.length, 2);
344
assert.deepStrictEqual(result1.map(a => a.annotation), ['1', '2']);
345
const result2 = vas.getAnnotationsIntersecting(new OffsetRange(5, 9));
346
assert.strictEqual(result2.length, 3);
347
assert.deepStrictEqual(result2.map(a => a.annotation), ['1', '2', '3']);
348
});
349
350
test('getAnnotationsIntersecting 3', () => {
351
const vas = fromVisual('[1:Lorem] ipsum [2:dolor]');
352
const result1 = vas.getAnnotationsIntersecting(new OffsetRange(4, 13));
353
assert.strictEqual(result1.length, 2);
354
assert.deepStrictEqual(result1.map(a => a.annotation), ['1', '2']);
355
vas.setAnnotations(updateFromVisual('[3:Lore]m[4: ipsu]'));
356
assertVisual(vas, '[3:Lore]m[4: ipsu]m [2:dolor]');
357
const result2 = vas.getAnnotationsIntersecting(new OffsetRange(7, 13));
358
assert.strictEqual(result2.length, 2);
359
assert.deepStrictEqual(result2.map(a => a.annotation), ['4', '2']);
360
});
361
362
test('getAnnotationsIntersecting 4', () => {
363
const vas = fromVisual('[1:Lorem ipsum] sit');
364
vas.setAnnotations(updateFromVisual('Lorem ipsum [2:sit]'));
365
const result = vas.getAnnotationsIntersecting(new OffsetRange(2, 8));
366
assert.strictEqual(result.length, 1);
367
assert.deepStrictEqual(result.map(a => a.annotation), ['1']);
368
});
369
370
test('getAnnotationsIntersecting 5', () => {
371
const vas = fromVisual('[1:Lorem ipsum] [2:dol] [3:or]');
372
const result = vas.getAnnotationsIntersecting(new OffsetRange(1, 16));
373
assert.strictEqual(result.length, 3);
374
assert.deepStrictEqual(result.map(a => a.annotation), ['1', '2', '3']);
375
});
376
377
test('applyEdit 1 - deletion within annotation', () => {
378
const result = visualizeEdit(
379
'[1:Lorem] ipsum [2:dolor] sit [3:amet]',
380
editDelete(0, 3)
381
);
382
assert.strictEqual(result.after, '[1:em] ipsum [2:dolor] sit [3:amet]');
383
});
384
385
test('applyEdit 2 - deletion and insertion within annotation', () => {
386
const result = visualizeEdit(
387
'[1:Lorem] ipsum [2:dolor] sit [3:amet]',
388
editReplace(1, 3, 'XXXXX')
389
);
390
assert.strictEqual(result.after, '[1:LXXXXXem] ipsum [2:dolor] sit [3:amet]');
391
});
392
393
test('applyEdit 3 - deletion across several annotations', () => {
394
const result = visualizeEdit(
395
'[1:Lorem] ipsum [2:dolor] sit [3:amet]',
396
editReplace(4, 22, 'XXXXX')
397
);
398
assert.strictEqual(result.after, '[1:LoreXXXXX][3:amet]');
399
});
400
401
test('applyEdit 4 - deletion between annotations', () => {
402
const result = visualizeEdit(
403
'[1:Lorem ip]sum and [2:dolor] sit [3:amet]',
404
editDelete(10, 12)
405
);
406
assert.strictEqual(result.after, '[1:Lorem ip]suand [2:dolor] sit [3:amet]');
407
});
408
409
test('applyEdit 5 - deletion that covers annotation', () => {
410
const result = visualizeEdit(
411
'[1:Lorem] ipsum [2:dolor] sit [3:amet]',
412
editDelete(0, 5)
413
);
414
assert.strictEqual(result.after, ' ipsum [2:dolor] sit [3:amet]');
415
});
416
417
test('applyEdit 6 - several edits', () => {
418
const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');
419
const edit = StringEdit.compose([
420
StringEdit.replace(new OffsetRange(0, 6), ''),
421
StringEdit.replace(new OffsetRange(6, 12), ''),
422
StringEdit.replace(new OffsetRange(12, 17), '')
423
]);
424
vas.applyEdit(edit);
425
assertVisual(vas, 'ipsum sit [3:am]');
426
});
427
428
test('applyEdit 7 - several edits', () => {
429
const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');
430
const edit1 = StringEdit.replace(new OffsetRange(0, 3), 'XXXX');
431
const edit2 = StringEdit.replace(new OffsetRange(0, 2), '');
432
vas.applyEdit(edit1.compose(edit2));
433
assertVisual(vas, '[1:XXem] ipsum [2:dolor] sit [3:amet]');
434
});
435
436
test('applyEdit 9 - insertion at end of annotation', () => {
437
const result = visualizeEdit(
438
'[1:Lorem] ipsum [2:dolor] sit [3:amet]',
439
editInsert(17, 'XXX')
440
);
441
assert.strictEqual(result.after, '[1:Lorem] ipsum [2:dolor]XXX sit [3:amet]');
442
});
443
444
test('applyEdit 10 - insertion in middle of annotation', () => {
445
const result = visualizeEdit(
446
'[1:Lorem] ipsum [2:dolor] sit [3:amet]',
447
editInsert(14, 'XXX')
448
);
449
assert.strictEqual(result.after, '[1:Lorem] ipsum [2:doXXXlor] sit [3:amet]');
450
});
451
452
test('applyEdit 11 - replacement consuming annotation', () => {
453
const result = visualizeEdit(
454
'[1:L]o[2:rem] [3:i]',
455
editReplace(1, 6, 'X')
456
);
457
assert.strictEqual(result.after, '[1:L]X[3:i]');
458
});
459
460
test('applyEdit 12 - multiple disjoint edits', () => {
461
const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet!] [4:done]');
462
463
const edit = StringEdit.compose([
464
StringEdit.insert(0, 'X'),
465
StringEdit.delete(new OffsetRange(12, 13)),
466
StringEdit.replace(new OffsetRange(21, 22), 'YY'),
467
StringEdit.replace(new OffsetRange(28, 32), 'Z')
468
]);
469
vas.applyEdit(edit);
470
assertVisual(vas, 'X[1:Lorem] ipsum[2:dolor] sitYY[3:amet!]Z[4:e]');
471
});
472
473
test('applyEdit 13 - edit on the left border', () => {
474
const result = visualizeEdit(
475
'lorem ipsum dolor[1: ]',
476
editInsert(17, 'X')
477
);
478
assert.strictEqual(result.after, 'lorem ipsum dolorX[1: ]');
479
});
480
481
test('rebase', () => {
482
const a = new VisualAnnotatedString(
483
new AnnotatedString<string>([{ range: new OffsetRange(2, 5), annotation: '1' }]),
484
'sitamet'
485
);
486
const b = a.clone();
487
const update: AnnotationsUpdate<string> = AnnotationsUpdate.create([{ range: new OffsetRange(4, 5), annotation: '2' }]);
488
489
b.setAnnotations(update);
490
const edit: StringEdit = StringEdit.replace(new OffsetRange(1, 6), 'XXX');
491
492
a.applyEdit(edit);
493
b.applyEdit(edit);
494
495
update.rebase(edit);
496
497
a.setAnnotations(update);
498
assert.deepStrictEqual(a.getAllAnnotations(), b.getAllAnnotations());
499
});
500
});
501
502