Path: blob/main/src/vs/editor/test/common/model/annotations.test.ts
5239 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';7import { AnnotatedString, AnnotationsUpdate, IAnnotation, IAnnotationUpdate } from '../../../common/model/tokens/annotations.js';8import { OffsetRange } from '../../../common/core/ranges/offsetRange.js';9import { StringEdit } from '../../../common/core/edits/stringEdit.js';1011// ============================================================================12// Visual Annotation Test Infrastructure13// ============================================================================14// This infrastructure allows representing annotations visually using brackets:15// - '[id:text]' marks an annotation with the given id covering 'text'16// - Plain text represents unannotated content17//18// Example: "Lorem [1:ipsum] dolor [2:sit] amet" represents:19// - annotation "1" at offset 6-11 (content "ipsum")20// - annotation "2" at offset 18-21 (content "sit")21//22// For updates:23// - '[id:text]' sets an annotation24// - '<id:text>' deletes an annotation in that range25// ============================================================================2627/**28* Parses a visual string representation into annotations.29* The visual string uses '[id:text]' to mark annotation boundaries.30* The id becomes the annotation value, and text is the annotated content.31*/32function parseVisualAnnotations(visual: string): { annotations: IAnnotation<string>[]; baseString: string } {33const annotations: IAnnotation<string>[] = [];34let baseString = '';35let i = 0;3637while (i < visual.length) {38if (visual[i] === '[') {39// Find the colon and closing bracket40const colonIdx = visual.indexOf(':', i + 1);41const closeIdx = visual.indexOf(']', colonIdx + 1);42if (colonIdx === -1 || closeIdx === -1) {43throw new Error(`Invalid annotation format at position ${i}`);44}45const id = visual.substring(i + 1, colonIdx);46const text = visual.substring(colonIdx + 1, closeIdx);47const startOffset = baseString.length;48baseString += text;49annotations.push({ range: new OffsetRange(startOffset, baseString.length), annotation: id });50i = closeIdx + 1;51} else {52baseString += visual[i];53i++;54}55}5657return { annotations, baseString };58}5960/**61* Converts annotations to a visual string representation.62* Uses '[id:text]' to mark annotation boundaries.63*64* @param annotations - The annotations to visualize65* @param baseString - The base string content66*/67function toVisualString(68annotations: IAnnotation<string>[],69baseString: string70): string {71if (annotations.length === 0) {72return baseString;73}7475// Sort annotations by start position76const sortedAnnotations = [...annotations].sort((a, b) => a.range.start - b.range.start);7778// Build the visual representation79let result = '';80let pos = 0;8182for (const ann of sortedAnnotations) {83// Add plain text before this annotation84result += baseString.substring(pos, ann.range.start);85// Add annotated content with id86const annotatedText = baseString.substring(ann.range.start, ann.range.endExclusive);87result += `[${ann.annotation}:${annotatedText}]`;88pos = ann.range.endExclusive;89}9091// Add remaining text after last annotation92result += baseString.substring(pos);9394return result;95}9697/**98* Represents an AnnotatedString with its base string for visual testing.99*/100class VisualAnnotatedString {101constructor(102public readonly annotatedString: AnnotatedString<string>,103public baseString: string104) { }105106setAnnotations(update: AnnotationsUpdate<string>): void {107this.annotatedString.setAnnotations(update);108}109110applyEdit(edit: StringEdit): void {111this.annotatedString.applyEdit(edit);112this.baseString = edit.apply(this.baseString);113}114115getAnnotationsIntersecting(range: OffsetRange): IAnnotation<string>[] {116return this.annotatedString.getAnnotationsIntersecting(range);117}118119getAllAnnotations(): IAnnotation<string>[] {120return this.annotatedString.getAllAnnotations();121}122123clone(): VisualAnnotatedString {124return new VisualAnnotatedString(this.annotatedString.clone() as AnnotatedString<string>, this.baseString);125}126}127128/**129* Creates a VisualAnnotatedString from a visual representation.130*/131function fromVisual(visual: string): VisualAnnotatedString {132const { annotations, baseString } = parseVisualAnnotations(visual);133return new VisualAnnotatedString(new AnnotatedString<string>(annotations), baseString);134}135136/**137* Converts a VisualAnnotatedString to a visual representation.138*/139function toVisual(vas: VisualAnnotatedString): string {140return toVisualString(vas.getAllAnnotations(), vas.baseString);141}142143/**144* Parses visual update annotations, where:145* - '[id:text]' represents an annotation to set146* - '<id:text>' represents an annotation to delete (range is tracked but annotation is undefined)147*/148function parseVisualUpdate(visual: string): { updates: IAnnotationUpdate<string>[]; baseString: string } {149const updates: IAnnotationUpdate<string>[] = [];150let baseString = '';151let i = 0;152153while (i < visual.length) {154if (visual[i] === '[') {155// Set annotation: [id:text]156const colonIdx = visual.indexOf(':', i + 1);157const closeIdx = visual.indexOf(']', colonIdx + 1);158if (colonIdx === -1 || closeIdx === -1) {159throw new Error(`Invalid annotation format at position ${i}`);160}161const id = visual.substring(i + 1, colonIdx);162const text = visual.substring(colonIdx + 1, closeIdx);163const startOffset = baseString.length;164baseString += text;165updates.push({ range: new OffsetRange(startOffset, baseString.length), annotation: id });166i = closeIdx + 1;167} else if (visual[i] === '<') {168// Delete annotation: <id:text>169const colonIdx = visual.indexOf(':', i + 1);170const closeIdx = visual.indexOf('>', colonIdx + 1);171if (colonIdx === -1 || closeIdx === -1) {172throw new Error(`Invalid delete format at position ${i}`);173}174const text = visual.substring(colonIdx + 1, closeIdx);175const startOffset = baseString.length;176baseString += text;177updates.push({ range: new OffsetRange(startOffset, baseString.length), annotation: undefined });178i = closeIdx + 1;179} else {180baseString += visual[i];181i++;182}183}184185return { updates, baseString };186}187188/**189* Creates an AnnotationsUpdate from a visual representation.190*/191function updateFromVisual(...visuals: string[]): AnnotationsUpdate<string> {192const updates: IAnnotationUpdate<string>[] = [];193194for (const visual of visuals) {195const { updates: parsedUpdates } = parseVisualUpdate(visual);196updates.push(...parsedUpdates);197}198199return AnnotationsUpdate.create(updates);200}201202/**203* Helper to create a StringEdit from visual notation.204* Uses a pattern matching approach where:205* - 'd' marks positions to delete206* - 'i:text:' inserts 'text' at the marked position207*208* Simpler approach: just use offset-based helpers209*/210function editDelete(start: number, end: number): StringEdit {211return StringEdit.replace(new OffsetRange(start, end), '');212}213214function editInsert(pos: number, text: string): StringEdit {215return StringEdit.insert(pos, text);216}217218function editReplace(start: number, end: number, text: string): StringEdit {219return StringEdit.replace(new OffsetRange(start, end), text);220}221222/**223* Asserts that a VisualAnnotatedString matches the expected visual representation.224* Only compares annotations, not the base string (since setAnnotations doesn't change the base string).225*/226function assertVisual(vas: VisualAnnotatedString, expectedVisual: string): void {227const actual = toVisual(vas);228const { annotations: expectedAnnotations } = parseVisualAnnotations(expectedVisual);229const actualAnnotations = vas.getAllAnnotations();230231// Compare annotations for better error messages232if (actualAnnotations.length !== expectedAnnotations.length) {233assert.fail(234`Annotation count mismatch.\n` +235` Expected: ${expectedVisual}\n` +236` Actual: ${actual}\n` +237` Expected ${expectedAnnotations.length} annotations, got ${actualAnnotations.length}`238);239}240241for (let i = 0; i < actualAnnotations.length; i++) {242const expected = expectedAnnotations[i];243const actualAnn = actualAnnotations[i];244if (actualAnn.range.start !== expected.range.start || actualAnn.range.endExclusive !== expected.range.endExclusive) {245assert.fail(246`Annotation ${i} range mismatch.\n` +247` Expected: (${expected.range.start}, ${expected.range.endExclusive})\n` +248` Actual: (${actualAnn.range.start}, ${actualAnn.range.endExclusive})\n` +249` Expected visual: ${expectedVisual}\n` +250` Actual visual: ${actual}`251);252}253if (actualAnn.annotation !== expected.annotation) {254assert.fail(255`Annotation ${i} value mismatch.\n` +256` Expected: "${expected.annotation}"\n` +257` Actual: "${actualAnn.annotation}"`258);259}260}261}262263/**264* Helper to visualize the effect of an edit on annotations.265* Returns both before and after states as visual strings.266*/267function visualizeEdit(268beforeAnnotations: string,269edit: StringEdit270): { before: string; after: string } {271const vas = fromVisual(beforeAnnotations);272const before = toVisual(vas);273274vas.applyEdit(edit);275276const after = toVisual(vas);277return { before, after };278}279280// ============================================================================281// Visual Annotations Test Suite282// ============================================================================283// These tests use a visual representation for better readability:284// - '[id:text]' marks annotated regions with id and content285// - Plain text represents unannotated content286// - '<id:text>' marks regions to delete (in updates)287//288// Example: "Lorem [1:ipsum] dolor [2:sit] amet" represents two annotations:289// "1" at (6,11) covering "ipsum", "2" at (18,21) covering "sit"290// ============================================================================291292suite('Annotations Suite', () => {293294ensureNoDisposablesAreLeakedInTestSuite();295296test('setAnnotations 1', () => {297const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');298vas.setAnnotations(updateFromVisual('[4:Lorem i]'));299assertVisual(vas, '[4:Lorem i]psum [2:dolor] sit [3:amet]');300vas.setAnnotations(updateFromVisual('Lorem ip[5:s]'));301assertVisual(vas, '[4:Lorem i]p[5:s]um [2:dolor] sit [3:amet]');302});303304test('setAnnotations 2', () => {305const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');306vas.setAnnotations(updateFromVisual(307'L<_:orem ipsum d>',308'[4:Lorem ]'309));310assertVisual(vas, '[4:Lorem ]ipsum dolor sit [3:amet]');311vas.setAnnotations(updateFromVisual(312'Lorem <_:ipsum dolor sit amet>',313'[5:Lor]'314));315assertVisual(vas, '[5:Lor]em ipsum dolor sit amet');316vas.setAnnotations(updateFromVisual('L[6:or]'));317assertVisual(vas, 'L[6:or]em ipsum dolor sit amet');318});319320test('setAnnotations 3', () => {321const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');322vas.setAnnotations(updateFromVisual('Lore[4:m ipsum dolor ]'));323assertVisual(vas, 'Lore[4:m ipsum dolor ]sit [3:amet]');324vas.setAnnotations(updateFromVisual('Lorem ipsum dolor sit [5:a]'));325assertVisual(vas, 'Lore[4:m ipsum dolor ]sit [5:a]met');326});327328test('setAnnotations 4', () => {329// 54 chars before 'i': "Lorem ipsum dolor sit amet, consectetur adipiscing el"330const vas = fromVisual('Lorem ipsum dolor sit amet, consectetur adipiscing el[:it]');331vas.setAnnotations(updateFromVisual('Lorem ipsum dolor sit amet, consectetur adipiscing el<_:i>t'));332assertVisual(vas, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit');333});334335test('getAnnotationsIntersecting 1', () => {336const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');337const result1 = vas.getAnnotationsIntersecting(new OffsetRange(0, 13));338assert.strictEqual(result1.length, 2);339assert.deepStrictEqual(result1.map(a => a.annotation), ['1', '2']);340const result2 = vas.getAnnotationsIntersecting(new OffsetRange(0, 22));341assert.strictEqual(result2.length, 3);342assert.deepStrictEqual(result2.map(a => a.annotation), ['1', '2', '3']);343});344345test('getAnnotationsIntersecting 2', () => {346const vas = fromVisual('[1:Lorem] [2:i]p[3:s]');347348const result1 = vas.getAnnotationsIntersecting(new OffsetRange(5, 7));349assert.strictEqual(result1.length, 1);350assert.deepStrictEqual(result1.map(a => a.annotation), ['2']);351const result2 = vas.getAnnotationsIntersecting(new OffsetRange(5, 9));352assert.strictEqual(result2.length, 2);353assert.deepStrictEqual(result2.map(a => a.annotation), ['2', '3']);354});355356test('getAnnotationsIntersecting 3', () => {357const vas = fromVisual('[1:Lorem] ipsum [2:dolor]');358const result1 = vas.getAnnotationsIntersecting(new OffsetRange(4, 13));359assert.strictEqual(result1.length, 2);360assert.deepStrictEqual(result1.map(a => a.annotation), ['1', '2']);361vas.setAnnotations(updateFromVisual('[3:Lore]m[4: ipsu]'));362assertVisual(vas, '[3:Lore]m[4: ipsu]m [2:dolor]');363const result2 = vas.getAnnotationsIntersecting(new OffsetRange(7, 13));364assert.strictEqual(result2.length, 2);365assert.deepStrictEqual(result2.map(a => a.annotation), ['4', '2']);366});367368test('getAnnotationsIntersecting 4', () => {369const vas = fromVisual('[1:Lorem ipsum] sit');370vas.setAnnotations(updateFromVisual('Lorem ipsum [2:sit]'));371const result = vas.getAnnotationsIntersecting(new OffsetRange(2, 8));372assert.strictEqual(result.length, 1);373assert.deepStrictEqual(result.map(a => a.annotation), ['1']);374});375376test('getAnnotationsIntersecting 5', () => {377const vas = fromVisual('[1:Lorem ipsum] [2:dol] [3:or]');378const result = vas.getAnnotationsIntersecting(new OffsetRange(1, 16));379assert.strictEqual(result.length, 3);380assert.deepStrictEqual(result.map(a => a.annotation), ['1', '2', '3']);381});382383test('getAnnotationsIntersecting 6', () => {384const vas = fromVisual('[1:Lorem ][2:ip][3:sum]');385const result = vas.getAnnotationsIntersecting(new OffsetRange(6, 6));386assert.strictEqual(result.length, 1);387assert.deepStrictEqual(result.map(a => a.annotation), ['2']);388});389390test('applyEdit 1 - deletion within annotation', () => {391const result = visualizeEdit(392'[1:Lorem] ipsum [2:dolor] sit [3:amet]',393editDelete(0, 3)394);395assert.strictEqual(result.after, '[1:em] ipsum [2:dolor] sit [3:amet]');396});397398test('applyEdit 2 - deletion and insertion within annotation', () => {399const result = visualizeEdit(400'[1:Lorem] ipsum [2:dolor] sit [3:amet]',401editReplace(1, 3, 'XXXXX')402);403assert.strictEqual(result.after, '[1:LXXXXXem] ipsum [2:dolor] sit [3:amet]');404});405406test('applyEdit 3 - deletion across several annotations', () => {407const result = visualizeEdit(408'[1:Lorem] ipsum [2:dolor] sit [3:amet]',409editReplace(4, 22, 'XXXXX')410);411assert.strictEqual(result.after, '[1:LoreXXXXX][3:amet]');412});413414test('applyEdit 4 - deletion between annotations', () => {415const result = visualizeEdit(416'[1:Lorem ip]sum and [2:dolor] sit [3:amet]',417editDelete(10, 12)418);419assert.strictEqual(result.after, '[1:Lorem ip]suand [2:dolor] sit [3:amet]');420});421422test('applyEdit 5 - deletion that covers annotation', () => {423const result = visualizeEdit(424'[1:Lorem] ipsum [2:dolor] sit [3:amet]',425editDelete(0, 5)426);427assert.strictEqual(result.after, ' ipsum [2:dolor] sit [3:amet]');428});429430test('applyEdit 6 - several edits', () => {431const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');432const edit = StringEdit.compose([433StringEdit.replace(new OffsetRange(0, 6), ''),434StringEdit.replace(new OffsetRange(6, 12), ''),435StringEdit.replace(new OffsetRange(12, 17), '')436]);437vas.applyEdit(edit);438assertVisual(vas, 'ipsum sit [3:am]');439});440441test('applyEdit 7 - several edits', () => {442const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');443const edit1 = StringEdit.replace(new OffsetRange(0, 3), 'XXXX');444const edit2 = StringEdit.replace(new OffsetRange(0, 2), '');445vas.applyEdit(edit1.compose(edit2));446assertVisual(vas, '[1:XXem] ipsum [2:dolor] sit [3:amet]');447});448449test('applyEdit 9 - insertion at end of annotation', () => {450const result = visualizeEdit(451'[1:Lorem] ipsum [2:dolor] sit [3:amet]',452editInsert(17, 'XXX')453);454assert.strictEqual(result.after, '[1:Lorem] ipsum [2:dolor]XXX sit [3:amet]');455});456457test('applyEdit 10 - insertion in middle of annotation', () => {458const result = visualizeEdit(459'[1:Lorem] ipsum [2:dolor] sit [3:amet]',460editInsert(14, 'XXX')461);462assert.strictEqual(result.after, '[1:Lorem] ipsum [2:doXXXlor] sit [3:amet]');463});464465test('applyEdit 11 - replacement consuming annotation', () => {466const result = visualizeEdit(467'[1:L]o[2:rem] [3:i]',468editReplace(1, 6, 'X')469);470assert.strictEqual(result.after, '[1:L]X[3:i]');471});472473test('applyEdit 12 - multiple disjoint edits', () => {474const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet!] [4:done]');475476const edit = StringEdit.compose([477StringEdit.insert(0, 'X'),478StringEdit.delete(new OffsetRange(12, 13)),479StringEdit.replace(new OffsetRange(21, 22), 'YY'),480StringEdit.replace(new OffsetRange(28, 32), 'Z')481]);482vas.applyEdit(edit);483assertVisual(vas, 'X[1:Lorem] ipsum[2:dolor] sitYY[3:amet!]Z[4:e]');484});485486test('applyEdit 13 - edit on the left border', () => {487const result = visualizeEdit(488'lorem ipsum dolor[1: ]',489editInsert(17, 'X')490);491assert.strictEqual(result.after, 'lorem ipsum dolorX[1: ]');492});493494test('rebase', () => {495const a = new VisualAnnotatedString(496new AnnotatedString<string>([{ range: new OffsetRange(2, 5), annotation: '1' }]),497'sitamet'498);499const b = a.clone();500const update: AnnotationsUpdate<string> = AnnotationsUpdate.create([{ range: new OffsetRange(4, 5), annotation: '2' }]);501502b.setAnnotations(update);503const edit: StringEdit = StringEdit.replace(new OffsetRange(1, 6), 'XXX');504505a.applyEdit(edit);506b.applyEdit(edit);507508update.rebase(edit);509510a.setAnnotations(update);511assert.deepStrictEqual(a.getAllAnnotations(), b.getAllAnnotations());512});513});514515516