Path: blob/main/src/vs/editor/test/common/model/annotations.test.ts
4779 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('getAnnotationsIntersecting 1', () => {329const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');330const result1 = vas.getAnnotationsIntersecting(new OffsetRange(0, 13));331assert.strictEqual(result1.length, 2);332assert.deepStrictEqual(result1.map(a => a.annotation), ['1', '2']);333const result2 = vas.getAnnotationsIntersecting(new OffsetRange(0, 22));334assert.strictEqual(result2.length, 3);335assert.deepStrictEqual(result2.map(a => a.annotation), ['1', '2', '3']);336});337338test('getAnnotationsIntersecting 2', () => {339const vas = fromVisual('[1:Lorem] [2:i]p[3:s]');340341const result1 = vas.getAnnotationsIntersecting(new OffsetRange(5, 7));342assert.strictEqual(result1.length, 2);343assert.deepStrictEqual(result1.map(a => a.annotation), ['1', '2']);344const result2 = vas.getAnnotationsIntersecting(new OffsetRange(5, 9));345assert.strictEqual(result2.length, 3);346assert.deepStrictEqual(result2.map(a => a.annotation), ['1', '2', '3']);347});348349test('getAnnotationsIntersecting 3', () => {350const vas = fromVisual('[1:Lorem] ipsum [2:dolor]');351const result1 = vas.getAnnotationsIntersecting(new OffsetRange(4, 13));352assert.strictEqual(result1.length, 2);353assert.deepStrictEqual(result1.map(a => a.annotation), ['1', '2']);354vas.setAnnotations(updateFromVisual('[3:Lore]m[4: ipsu]'));355assertVisual(vas, '[3:Lore]m[4: ipsu]m [2:dolor]');356const result2 = vas.getAnnotationsIntersecting(new OffsetRange(7, 13));357assert.strictEqual(result2.length, 2);358assert.deepStrictEqual(result2.map(a => a.annotation), ['4', '2']);359});360361test('getAnnotationsIntersecting 4', () => {362const vas = fromVisual('[1:Lorem ipsum] sit');363vas.setAnnotations(updateFromVisual('Lorem ipsum [2:sit]'));364const result = vas.getAnnotationsIntersecting(new OffsetRange(2, 8));365assert.strictEqual(result.length, 1);366assert.deepStrictEqual(result.map(a => a.annotation), ['1']);367});368369test('getAnnotationsIntersecting 5', () => {370const vas = fromVisual('[1:Lorem ipsum] [2:dol] [3:or]');371const result = vas.getAnnotationsIntersecting(new OffsetRange(1, 16));372assert.strictEqual(result.length, 3);373assert.deepStrictEqual(result.map(a => a.annotation), ['1', '2', '3']);374});375376test('applyEdit 1 - deletion within annotation', () => {377const result = visualizeEdit(378'[1:Lorem] ipsum [2:dolor] sit [3:amet]',379editDelete(0, 3)380);381assert.strictEqual(result.after, '[1:em] ipsum [2:dolor] sit [3:amet]');382});383384test('applyEdit 2 - deletion and insertion within annotation', () => {385const result = visualizeEdit(386'[1:Lorem] ipsum [2:dolor] sit [3:amet]',387editReplace(1, 3, 'XXXXX')388);389assert.strictEqual(result.after, '[1:LXXXXXem] ipsum [2:dolor] sit [3:amet]');390});391392test('applyEdit 3 - deletion across several annotations', () => {393const result = visualizeEdit(394'[1:Lorem] ipsum [2:dolor] sit [3:amet]',395editReplace(4, 22, 'XXXXX')396);397assert.strictEqual(result.after, '[1:LoreXXXXX][3:amet]');398});399400test('applyEdit 4 - deletion between annotations', () => {401const result = visualizeEdit(402'[1:Lorem ip]sum and [2:dolor] sit [3:amet]',403editDelete(10, 12)404);405assert.strictEqual(result.after, '[1:Lorem ip]suand [2:dolor] sit [3:amet]');406});407408test('applyEdit 5 - deletion that covers annotation', () => {409const result = visualizeEdit(410'[1:Lorem] ipsum [2:dolor] sit [3:amet]',411editDelete(0, 5)412);413assert.strictEqual(result.after, ' ipsum [2:dolor] sit [3:amet]');414});415416test('applyEdit 6 - several edits', () => {417const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');418const edit = StringEdit.compose([419StringEdit.replace(new OffsetRange(0, 6), ''),420StringEdit.replace(new OffsetRange(6, 12), ''),421StringEdit.replace(new OffsetRange(12, 17), '')422]);423vas.applyEdit(edit);424assertVisual(vas, 'ipsum sit [3:am]');425});426427test('applyEdit 7 - several edits', () => {428const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet]');429const edit1 = StringEdit.replace(new OffsetRange(0, 3), 'XXXX');430const edit2 = StringEdit.replace(new OffsetRange(0, 2), '');431vas.applyEdit(edit1.compose(edit2));432assertVisual(vas, '[1:XXem] ipsum [2:dolor] sit [3:amet]');433});434435test('applyEdit 9 - insertion at end of annotation', () => {436const result = visualizeEdit(437'[1:Lorem] ipsum [2:dolor] sit [3:amet]',438editInsert(17, 'XXX')439);440assert.strictEqual(result.after, '[1:Lorem] ipsum [2:dolor]XXX sit [3:amet]');441});442443test('applyEdit 10 - insertion in middle of annotation', () => {444const result = visualizeEdit(445'[1:Lorem] ipsum [2:dolor] sit [3:amet]',446editInsert(14, 'XXX')447);448assert.strictEqual(result.after, '[1:Lorem] ipsum [2:doXXXlor] sit [3:amet]');449});450451test('applyEdit 11 - replacement consuming annotation', () => {452const result = visualizeEdit(453'[1:L]o[2:rem] [3:i]',454editReplace(1, 6, 'X')455);456assert.strictEqual(result.after, '[1:L]X[3:i]');457});458459test('applyEdit 12 - multiple disjoint edits', () => {460const vas = fromVisual('[1:Lorem] ipsum [2:dolor] sit [3:amet!] [4:done]');461462const edit = StringEdit.compose([463StringEdit.insert(0, 'X'),464StringEdit.delete(new OffsetRange(12, 13)),465StringEdit.replace(new OffsetRange(21, 22), 'YY'),466StringEdit.replace(new OffsetRange(28, 32), 'Z')467]);468vas.applyEdit(edit);469assertVisual(vas, 'X[1:Lorem] ipsum[2:dolor] sitYY[3:amet!]Z[4:e]');470});471472test('applyEdit 13 - edit on the left border', () => {473const result = visualizeEdit(474'lorem ipsum dolor[1: ]',475editInsert(17, 'X')476);477assert.strictEqual(result.after, 'lorem ipsum dolorX[1: ]');478});479480test('rebase', () => {481const a = new VisualAnnotatedString(482new AnnotatedString<string>([{ range: new OffsetRange(2, 5), annotation: '1' }]),483'sitamet'484);485const b = a.clone();486const update: AnnotationsUpdate<string> = AnnotationsUpdate.create([{ range: new OffsetRange(4, 5), annotation: '2' }]);487488b.setAnnotations(update);489const edit: StringEdit = StringEdit.replace(new OffsetRange(1, 6), 'XXX');490491a.applyEdit(edit);492b.applyEdit(edit);493494update.rebase(edit);495496a.setAnnotations(update);497assert.deepStrictEqual(a.getAllAnnotations(), b.getAllAnnotations());498});499});500501502