From ffb6bcb6ab8fa2d562648fbb43512661fbc7dc20 Mon Sep 17 00:00:00 2001 From: Valery Bugakov Date: Fri, 22 Nov 2024 13:01:14 +0800 Subject: [PATCH] chore(audoedit): simplify diff utils and renderer data structures (#6172) - No functional changes. - Simplifies data structures used to calculate the diff between the current document content and predicted edits. - Simplifies data structures used to render autoedit decorations. - Removes redundant data structures and transformations happening between the diff calculation and the renderer call. - Adds granular `changes: LineChange` to `ModifiedLineInfo`, representing individual insertions and deletions in modified lines. This is helpful for troubleshooting purposes, and I plan to use it for the experimental inline renderer implementation that we discussed recently in Slack. - Updates the default autoedits renderer to use all the above changes. --- .../src/autoedits/renderer/decorators/base.ts | 75 ++- .../autoedits/renderer/decorators/common.ts | 51 -- .../renderer/decorators/default-decorator.ts | 160 +++-- .../src/autoedits/renderer/diff-utils.test.ts | 570 ++++++++++++++---- vscode/src/autoedits/renderer/diff-utils.ts | 434 ++++++------- vscode/src/autoedits/renderer/manager.ts | 30 +- .../autoedits/renderer/renderer-testing.ts | 12 +- vscode/src/autoedits/utils.ts | 36 +- .../src/completions/text-processing/utils.ts | 5 + 9 files changed, 840 insertions(+), 533 deletions(-) delete mode 100644 vscode/src/autoedits/renderer/decorators/common.ts diff --git a/vscode/src/autoedits/renderer/decorators/base.ts b/vscode/src/autoedits/renderer/decorators/base.ts index 9c66a91807f1..547f4ffef1ee 100644 --- a/vscode/src/autoedits/renderer/decorators/base.ts +++ b/vscode/src/autoedits/renderer/decorators/base.ts @@ -1,5 +1,4 @@ import type * as vscode from 'vscode' -import type { ModifiedRange } from '../diff-utils' /** * Represents a decorator that manages VS Code editor decorations for auto-edit suggestions. @@ -8,63 +7,77 @@ import type { ModifiedRange } from '../diff-utils' * that visualize proposed text changes in the editor. * * Lifecycle: - * - Single instance should be created per decoration session and disposed of when the decorations + * - A single instance should be created per decoration session and disposed of when the decorations * are no longer needed. * - Always call dispose() when the decorator is no longer needed to clean up resources. * - Dispose should always clear the decorations. * * Usage Pattern: * ```typescript - * const decorator = createAutoeditsDecorator(...); + * const decorator = createAutoEditsDecorator(...); * try { * decorator.setDecorations(decorationInfo); * ... * } finally { - * decorator.clearDecorations(); * decorator.dispose(); * } * ``` */ -export interface AutoeditsDecorator extends vscode.Disposable { +export interface AutoEditsDecorator extends vscode.Disposable { /** * Applies decorations to the editor based on the provided decoration information. * - * @param decorationInformation Contains the line-by-line information about text changes + * @param decorationInfo Contains the line-by-line information about text changes * and how they should be decorated in the editor. */ - setDecorations(decorationInformation: DecorationInformation): void + setDecorations(decorationInfo: DecorationInfo): void } /** - * Represents the different types of line decorations that can be applied. + * Represents a line of text with its change type and content. */ -export enum DecorationLineType { - /** Line has been modified from its original state */ - Modified = 0, - /** New line has been added */ - Added = 1, - /** Line has been removed */ - Removed = 2, - /** Line remains unchanged */ - Unchanged = 3, +export type DecorationLineInfo = AddedLineInfo | RemovedLineInfo | ModifiedLineInfo | UnchangedLineInfo + +export interface AddedLineInfo { + type: 'added' + text: string + /** `lineNumber` in the modified text */ + lineNumber: number +} + +export interface RemovedLineInfo { + type: 'removed' + text: string + /** `lineNumber` in the original text */ + lineNumber: number } -export interface DecorationLineInformation { - lineType: DecorationLineType - // Line number in the original text. The line number can be null if the line was added. - oldLineNumber: number | null - // Line number in the new predicted text. The line number can be null if the line was removed. - newLineNumber: number | null - // The text of the line in the original text. +export interface ModifiedLineInfo { + type: 'modified' oldText: string - // The text of the line in the new predicted text. newText: string - // The ranges of text that were modified in the line. - modifiedRanges: ModifiedRange[] + changes: LineChange[] + /** `lineNumber` in the modified text */ + lineNumber: number +} + +export interface UnchangedLineInfo { + type: 'unchanged' + text: string + /** `lineNumber` in the modified text */ + lineNumber: number +} + +export type LineChange = { + type: 'insert' | 'delete' + /** `range` in the modified text relative to the document start */ + range: vscode.Range + text: string } -export interface DecorationInformation { - lines: DecorationLineInformation[] - oldLines: string[] - newLines: string[] +export interface DecorationInfo { + modifiedLines: ModifiedLineInfo[] + removedLines: RemovedLineInfo[] + addedLines: AddedLineInfo[] + unchangedLines: UnchangedLineInfo[] } diff --git a/vscode/src/autoedits/renderer/decorators/common.ts b/vscode/src/autoedits/renderer/decorators/common.ts deleted file mode 100644 index fd8f25bd6d24..000000000000 --- a/vscode/src/autoedits/renderer/decorators/common.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type DecorationLineInformation, DecorationLineType } from './base' - -/** - * Checks if the only changes for modified lines are additions of text - */ -export function isOnlyAddingTextForModifiedLines( - decorationInformation: DecorationLineInformation[] -): boolean { - for (const line of decorationInformation) { - if (line.lineType !== DecorationLineType.Modified) { - continue - } - if (line.modifiedRanges.some(range => range.from1 !== range.to1)) { - return false - } - } - return true -} - -export function splitLineDecorationIntoLineTypes(decorationInformation: DecorationLineInformation[]): { - modifiedLines: DecorationLineInformation[] - removedLines: DecorationLineInformation[] - addedLines: DecorationLineInformation[] - unchangedLines: DecorationLineInformation[] -} { - const result = { - modifiedLines: [] as DecorationLineInformation[], - removedLines: [] as DecorationLineInformation[], - addedLines: [] as DecorationLineInformation[], - unchangedLines: [] as DecorationLineInformation[], - } - - for (const line of decorationInformation) { - switch (line.lineType) { - case DecorationLineType.Modified: - result.modifiedLines.push(line) - break - case DecorationLineType.Removed: - result.removedLines.push(line) - break - case DecorationLineType.Added: - result.addedLines.push(line) - break - case DecorationLineType.Unchanged: - result.unchangedLines.push(line) - break - } - } - - return result -} diff --git a/vscode/src/autoedits/renderer/decorators/default-decorator.ts b/vscode/src/autoedits/renderer/decorators/default-decorator.ts index c737e50bd1d0..cad8fa93379d 100644 --- a/vscode/src/autoedits/renderer/decorators/default-decorator.ts +++ b/vscode/src/autoedits/renderer/decorators/default-decorator.ts @@ -1,13 +1,7 @@ import * as vscode from 'vscode' import { ThemeColor } from 'vscode' import { GHOST_TEXT_COLOR } from '../../../commands/GhostHintDecorator' -import { - type AutoeditsDecorator, - type DecorationInformation, - type DecorationLineInformation, - DecorationLineType, -} from './base' -import { isOnlyAddingTextForModifiedLines, splitLineDecorationIntoLineTypes } from './common' +import type { AutoEditsDecorator, DecorationInfo, ModifiedLineInfo } from './base' interface AddedLinesDecorationInfo { ranges: [number, number][] @@ -15,7 +9,7 @@ interface AddedLinesDecorationInfo { lineText: string } -export class DefaultDecorator implements AutoeditsDecorator { +export class DefaultDecorator implements AutoEditsDecorator { private readonly decorationTypes: vscode.TextEditorDecorationType[] private readonly removedTextDecorationType: vscode.TextEditorDecorationType private readonly modifiedTextDecorationType: vscode.TextEditorDecorationType @@ -44,13 +38,13 @@ export class DefaultDecorator implements AutoeditsDecorator { this.addedLinesDecorationType = vscode.window.createTextEditorDecorationType({ backgroundColor: 'red', // SENTINEL (should not actually appear) before: { - backgroundColor: 'rgb(100, 255, 100, 0.1)', + backgroundColor: 'rgba(100, 255, 100, 0.1)', color: GHOST_TEXT_COLOR, height: '100%', }, }) this.insertMarkerDecorationType = vscode.window.createTextEditorDecorationType({ - border: '1px dashed rgb(100, 255, 100, 0.5)', + border: '1px dashed rgba(100, 255, 100, 0.5)', borderWidth: '1px 1px 0 0', }) @@ -72,32 +66,28 @@ export class DefaultDecorator implements AutoeditsDecorator { } /** - * Renders decorations using an inline diff strategy to show changes between two versions of text - * Split the decorations into three parts: + * Renders decorations using an inline diff strategy to show changes between two versions of text. + * It splits the decorations into three parts: * 1. Modified lines: Either show inline ghost text or a combination of ("red" decorations + "green" decorations) - * 2. Removed lines: Show Inline decoration with "red" marker indicating deletions - * 3. Added lines: Show Inline decoration with "green" marker indicating additions + * 2. Removed lines: Show inline decoration with "red" marker indicating deletions + * 3. Added lines: Show inline decoration with "green" marker indicating additions */ - public setDecorations(decorationInformation: DecorationInformation): void { - const { modifiedLines, removedLines, addedLines } = splitLineDecorationIntoLineTypes( - decorationInformation.lines - ) - const isOnlyAdditionsForModifiedLines = isOnlyAddingTextForModifiedLines(modifiedLines) - const removedLinesRanges = this.getNonModifiedLinesRanges( - removedLines - .filter(line => line.oldLineNumber !== null) - .map(line => line.oldLineNumber as number) - ) + public setDecorations(decorationInfo: DecorationInfo): void { + const { modifiedLines, removedLines, addedLines } = decorationInfo + + const removedLinesRanges = removedLines.map(line => this.createFullLineRange(line.lineNumber)) this.editor.setDecorations(this.removedTextDecorationType, removedLinesRanges) - if (addedLines.length > 0 || !isOnlyAdditionsForModifiedLines) { - this.renderDiffDecorations(decorationInformation) + if (addedLines.length > 0 || !isOnlyAddingTextForModifiedLines(modifiedLines)) { + this.renderDiffDecorations(decorationInfo) } else { this.renderInlineGhostTextDecorations(modifiedLines) } } - private renderDiffDecorations(decorationInformation: DecorationInformation): void { + private renderDiffDecorations(decorationInfo: DecorationInfo): void { + const { modifiedLines, addedLines, unchangedLines } = decorationInfo + // Display the removed range decorations const removedRanges: vscode.Range[] = [] const addedLinesInfo: AddedLinesDecorationInfo[] = [] @@ -108,48 +98,39 @@ export class DefaultDecorator implements AutoeditsDecorator { } | null = null // Handle modified lines - collect removed ranges and added decorations - for (const line of decorationInformation.lines) { - if ( - line.lineType !== DecorationLineType.Modified || - line.oldLineNumber === null || - line.newLineNumber === null - ) { - continue - } + for (const modifiedLine of modifiedLines) { + const changes = modifiedLine.changes + const addedRanges: [number, number][] = [] - for (const range of line.modifiedRanges) { - if (range.to1 > range.from1) { - removedRanges.push( - new vscode.Range(line.oldLineNumber, range.from1, line.oldLineNumber, range.to1) - ) - } - if (range.to2 > range.from2) { - addedRanges.push([range.from2, range.to2]) + for (const change of changes) { + if (change.type === 'delete') { + removedRanges.push(change.range) + } else if (change.type === 'insert') { + addedRanges.push([change.range.start.character, change.range.end.character]) } } if (addedRanges.length > 0) { - firstModifiedLineMatch = { - beforeLine: line.oldLineNumber, - afterLine: line.newLineNumber, + if (!firstModifiedLineMatch) { + firstModifiedLineMatch = { + beforeLine: modifiedLine.lineNumber, + afterLine: modifiedLine.lineNumber, + } } addedLinesInfo.push({ ranges: addedRanges, - afterLine: line.newLineNumber, - lineText: line.newText, + afterLine: modifiedLine.lineNumber, + lineText: modifiedLine.newText, }) } } this.editor.setDecorations(this.modifiedTextDecorationType, removedRanges) // Handle fully added lines - for (const line of decorationInformation.lines) { - if (line.lineType !== DecorationLineType.Added || line.newLineNumber === null) { - continue - } + for (const addedLine of addedLines) { addedLinesInfo.push({ - ranges: line.modifiedRanges.map(range => [range.from2, range.to2]), - afterLine: line.newLineNumber, - lineText: line.newText, + ranges: [], + afterLine: addedLine.lineNumber, + lineText: addedLine.text, }) } @@ -157,17 +138,15 @@ export class DefaultDecorator implements AutoeditsDecorator { const lineNumbers = addedLinesInfo.map(d => d.afterLine) const min = Math.min(...lineNumbers) const max = Math.max(...lineNumbers) - for (const line of decorationInformation.lines) { - if (line.lineType !== DecorationLineType.Unchanged || line.newLineNumber === null) { - continue - } - if (line.newLineNumber < min || line.newLineNumber > max) { + for (const unchangedLine of unchangedLines) { + const lineNumber = unchangedLine.lineNumber + if (lineNumber < min || lineNumber > max) { continue } addedLinesInfo.push({ ranges: [], - afterLine: line.newLineNumber, - lineText: line.newText, + afterLine: lineNumber, + lineText: unchangedLine.text, }) } // Sort addedLinesInfo by line number in ascending order @@ -182,12 +161,9 @@ export class DefaultDecorator implements AutoeditsDecorator { (firstModifiedLineMatch.afterLine - addedLinesInfo[0].afterLine) } - const replacerCol = Math.max( - ...decorationInformation.oldLines - .slice(startLine, startLine + addedLinesInfo.length) - .map(line => line.length) - ) // todo (hitesh): handle case when too many lines to fit in the editor + const oldLines = addedLinesInfo.map(info => this.editor.document.lineAt(info.afterLine)) + const replacerCol = Math.max(...oldLines.map(line => line.range.end.character)) this.renderAddedLinesDecorations(addedLinesInfo, startLine, replacerCol) } @@ -263,37 +239,27 @@ export class DefaultDecorator implements AutoeditsDecorator { this.editor.setDecorations(this.addedLinesDecorationType, replacerDecorations) } - private renderInlineGhostTextDecorations(decorationInformation: DecorationLineInformation[]): void { - const inlineModifiedRanges: vscode.DecorationOptions[] = [] - for (const line of decorationInformation) { - if (line.lineType !== DecorationLineType.Modified || line.oldLineNumber === null) { - continue - } - const modifiedRanges = line.modifiedRanges - for (const range of modifiedRanges) { - inlineModifiedRanges.push({ - range: new vscode.Range( - line.oldLineNumber, - range.from1, - line.oldLineNumber, - range.to1 - ), + private renderInlineGhostTextDecorations(decorationLines: ModifiedLineInfo[]): void { + const inlineModifiedRanges: vscode.DecorationOptions[] = decorationLines + .flatMap(line => line.changes) + .filter(change => change.type === 'insert') + .map(change => { + return { + range: change.range, renderOptions: { before: { - contentText: line.newText.slice(range.from2, range.to2), + contentText: change.text, }, }, - }) - } - } + } + }) + this.editor.setDecorations(this.suggesterType, inlineModifiedRanges) } - private getNonModifiedLinesRanges(lineNumbers: number[]): vscode.Range[] { - // Get the ranges of the lines that are not modified, i.e. fully removed or added lines - return lineNumbers.map( - line => new vscode.Range(line, 0, line, this.editor.document.lineAt(line).text.length) - ) + private createFullLineRange(lineNumber: number): vscode.Range { + const lineTextLength = this.editor.document.lineAt(lineNumber).text.length + return new vscode.Range(lineNumber, 0, lineNumber, lineTextLength) } public dispose(): void { @@ -387,3 +353,15 @@ function getCommonPrefix(s1: string, s2: string): string { } return commonPrefix } + +/** + * Checks if the only changes for modified lines are additions of text. + */ +export function isOnlyAddingTextForModifiedLines(modifiedLines: ModifiedLineInfo[]): boolean { + for (const modifiedLine of modifiedLines) { + if (modifiedLine.changes.some(change => change.type === 'delete')) { + return false + } + } + return true +} diff --git a/vscode/src/autoedits/renderer/diff-utils.test.ts b/vscode/src/autoedits/renderer/diff-utils.test.ts index 433852b8b315..7aa6fdfeea6e 100644 --- a/vscode/src/autoedits/renderer/diff-utils.test.ts +++ b/vscode/src/autoedits/renderer/diff-utils.test.ts @@ -1,154 +1,484 @@ import { describe, expect, it } from 'vitest' -import { getLineLevelDiff } from './diff-utils' +import { getDecorationInfo } from './diff-utils' -describe('getLineLevelDiff', () => { +import type { AddedLineInfo, DecorationInfo, ModifiedLineInfo } from './decorators/base' + +describe('getDecorationInfo', () => { it('should identify modified lines', () => { - const currentLines = ['line1', 'line2', 'line3'] - const predictedLines = ['line1', 'modified2', 'line3'] - - const result = getLineLevelDiff(currentLines, predictedLines) - - expect(result.modifiedLines).toEqual([{ oldNumber: 1, newNumber: 1 }]) - expect(result.addedLines).toEqual([]) - expect(result.removedLines).toEqual([]) - expect(result.unchangedLines).toEqual([ - { oldNumber: 0, newNumber: 0 }, - { oldNumber: 2, newNumber: 2 }, - ]) + const originalText = 'line1\nline2\nline3' + const modifiedText = 'line1\nmodified2\nline3' + + const decorationInfo = getDecorationInfo(originalText, modifiedText) + + const expected: DecorationInfo = { + addedLines: [], + removedLines: [], + modifiedLines: [ + { + type: 'modified', + lineNumber: 1, // Line number in the modified text + oldText: 'line2', + newText: 'modified2', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'line2', + }, + { + type: 'insert', + range: expect.anything(), + text: 'modified2', + }, + ], + }, + ], + unchangedLines: [ + { type: 'unchanged', lineNumber: 0, text: 'line1' }, + { type: 'unchanged', lineNumber: 2, text: 'line3' }, + ], + } + + expect(decorationInfo).toEqual(expected) }) it('should identify added lines', () => { - const currentLines = ['line1', 'line2'] - const predictedLines = ['line1', 'line2', 'line3'] - - const result = getLineLevelDiff(currentLines, predictedLines) - - expect(result.modifiedLines).toEqual([]) - expect(result.addedLines).toEqual([2]) - expect(result.removedLines).toEqual([]) - expect(result.unchangedLines).toEqual([ - { oldNumber: 0, newNumber: 0 }, - { oldNumber: 1, newNumber: 1 }, - ]) + const originalText = 'line1\nline2' + const modifiedText = 'line1\nline2\nline3' + + const decorationInfo = getDecorationInfo(originalText, modifiedText) + + const expected: DecorationInfo = { + addedLines: [{ type: 'added', lineNumber: 2, text: 'line3' }], + removedLines: [], + modifiedLines: [], + unchangedLines: [ + { type: 'unchanged', lineNumber: 0, text: 'line1' }, + { type: 'unchanged', lineNumber: 1, text: 'line2' }, + ], + } + + expect(decorationInfo).toEqual(expected) }) it('should identify removed lines', () => { - const currentLines = ['line1', 'line2', 'line3'] - const predictedLines = ['line1', 'line3'] - - const result = getLineLevelDiff(currentLines, predictedLines) - - expect(result.modifiedLines).toEqual([]) - expect(result.addedLines).toEqual([]) - expect(result.removedLines).toEqual([1]) - expect(result.unchangedLines).toEqual([ - { oldNumber: 0, newNumber: 0 }, - { oldNumber: 2, newNumber: 1 }, - ]) - }) + const originalText = 'line1\nline2\nline3' + const modifiedText = 'line1\nline3' - it('should handle changes with modified lines', () => { - const currentLines = ['line1', 'line2', 'line3', 'line4'] - const predictedLines = ['line1', 'modified2', 'newline', 'line4'] - - const result = getLineLevelDiff(currentLines, predictedLines) - - expect(result.modifiedLines).toEqual([ - { oldNumber: 1, newNumber: 1 }, - { oldNumber: 2, newNumber: 2 }, - ]) - expect(result.addedLines).toEqual([]) - expect(result.removedLines).toEqual([]) - expect(result.unchangedLines).toEqual([ - { oldNumber: 0, newNumber: 0 }, - { oldNumber: 3, newNumber: 3 }, - ]) + const decorationInfo = getDecorationInfo(originalText, modifiedText) + + const expected: DecorationInfo = { + addedLines: [], + removedLines: [{ type: 'removed', lineNumber: 1, text: 'line2' }], + modifiedLines: [], + unchangedLines: [ + { type: 'unchanged', lineNumber: 0, text: 'line1' }, + { type: 'unchanged', lineNumber: 1, text: 'line3' }, + ], + } + + expect(decorationInfo).toEqual(expected) }) - it('should handle empty input arrays', () => { - const result = getLineLevelDiff([], []) + it('should handle changes with multiple modified lines', () => { + const originalText = 'line1\nline2\nline3\nline4' + const modifiedText = 'line1\nmodified2\nnewline\nline4' + + const decorationInfo = getDecorationInfo(originalText, modifiedText) + + const expected: DecorationInfo = { + addedLines: [], + removedLines: [], + modifiedLines: [ + { + type: 'modified', + lineNumber: 1, + oldText: 'line2', + newText: 'modified2', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'line2', + }, + { + type: 'insert', + range: expect.anything(), + text: 'modified2', + }, + ], + }, + { + type: 'modified', + lineNumber: 2, + oldText: 'line3', + newText: 'newline', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'line3', + }, + { + type: 'insert', + range: expect.anything(), + text: 'newline', + }, + ], + }, + ], + unchangedLines: [ + { type: 'unchanged', lineNumber: 0, text: 'line1' }, + { type: 'unchanged', lineNumber: 3, text: 'line4' }, + ], + } - expect(result.modifiedLines).toEqual([]) - expect(result.addedLines).toEqual([]) - expect(result.removedLines).toEqual([]) - expect(result.unchangedLines).toEqual([]) + expect(decorationInfo).toEqual(expected) }) - it('should handle multiple modifications, additions and removals', () => { - const currentLines = ['keep1', 'remove1', 'modify1', 'keep2', 'remove2', 'modify2', 'keep3'] - const predictedLines = ['keep1', 'modified1', 'keep2', 'add1', 'modified2', 'add2', 'keep3'] + it('should handle empty input', () => { + const originalText = '' + const modifiedText = '' - const result = getLineLevelDiff(currentLines, predictedLines) + const decorationInfo = getDecorationInfo(originalText, modifiedText) + + const expected: DecorationInfo = { + addedLines: [], + removedLines: [], + modifiedLines: [], + unchangedLines: [ + { + lineNumber: 0, + text: '', + type: 'unchanged', + }, + ], + } + + expect(decorationInfo).toEqual(expected) + }) - // Modified lines track both old and new line numbers - expect(result.modifiedLines).toEqual([ - { oldNumber: 1, newNumber: 1 }, - { oldNumber: 4, newNumber: 3 }, - { oldNumber: 5, newNumber: 4 }, - ]) + it('should handle multiple modifications, additions, and removals', () => { + const originalText = 'keep1\nremove1\nremoveLine1\nkeep2\nremove2\nmodify2\nkeep3' + const modifiedText = 'keep1\nmodified1\nkeep2\nadd1\nmodified2\nadd2\nkeep3' - // Added lines are tracked by their line number in the new text - expect(result.addedLines).toEqual([5]) + const decorationInfo = getDecorationInfo(originalText, modifiedText) - // Removed lines are tracked by their line number in the original text - expect(result.removedLines).toEqual([2]) + const expected: DecorationInfo = { + addedLines: [{ type: 'added', lineNumber: 5, text: 'add2' } as AddedLineInfo], + removedLines: [ + { + lineNumber: 2, + text: 'removeLine1', + type: 'removed', + }, + ], + modifiedLines: [ + { + type: 'modified', + lineNumber: 1, + oldText: 'remove1', + newText: 'modified1', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'remove1', + }, + { + type: 'insert', + range: expect.anything(), + text: 'modified1', + }, + ], + }, + { + type: 'modified', + lineNumber: 3, + oldText: 'remove2', + newText: 'add1', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'remove2', + }, + { + type: 'insert', + range: expect.anything(), + text: 'add1', + }, + ], + }, + { + type: 'modified', + lineNumber: 4, + oldText: 'modify2', + newText: 'modified2', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'modify2', + }, + { + type: 'insert', + range: expect.anything(), + text: 'modified2', + }, + ], + }, + ], + unchangedLines: [ + { type: 'unchanged', lineNumber: 0, text: 'keep1' }, + { type: 'unchanged', lineNumber: 2, text: 'keep2' }, + { type: 'unchanged', lineNumber: 6, text: 'keep3' }, + ], + } - // Unchanged lines track both old and new line numbers - expect(result.unchangedLines).toEqual([ - { oldNumber: 0, newNumber: 0 }, - { oldNumber: 3, newNumber: 2 }, - { oldNumber: 6, newNumber: 6 }, - ]) + expect(decorationInfo).toEqual(expected) }) it('should handle completely different content', () => { - const currentLines = ['line1', 'line2', 'line3'] - const predictedLines = ['different1', 'different2', 'different3'] - - const result = getLineLevelDiff(currentLines, predictedLines) - - expect(result.modifiedLines).toEqual([ - { oldNumber: 0, newNumber: 0 }, - { oldNumber: 1, newNumber: 1 }, - { oldNumber: 2, newNumber: 2 }, - ]) - expect(result.addedLines).toEqual([]) - expect(result.removedLines).toEqual([]) - expect(result.unchangedLines).toEqual([]) + const originalText = 'line1\nline2\nline3' + const modifiedText = 'different1\ndifferent2\ndifferent3' + + const decorationInfo = getDecorationInfo(originalText, modifiedText) + + const expected: DecorationInfo = { + addedLines: [], + removedLines: [], + modifiedLines: [ + { + type: 'modified', + lineNumber: 0, + oldText: 'line1', + newText: 'different1', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'line1', + }, + { + type: 'insert', + range: expect.anything(), + text: 'different1', + }, + ], + }, + { + type: 'modified', + lineNumber: 1, + oldText: 'line2', + newText: 'different2', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'line2', + }, + { + type: 'insert', + range: expect.anything(), + text: 'different2', + }, + ], + }, + { + type: 'modified', + lineNumber: 2, + oldText: 'line3', + newText: 'different3', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'line3', + }, + { + type: 'insert', + range: expect.anything(), + text: 'different3', + }, + ], + }, + ], + unchangedLines: [], + } + + expect(decorationInfo).toEqual(expected) }) - it('should handle one empty array', () => { - const currentLines = ['line1', 'line2', 'line3'] - const emptyLines: string[] = [] + it('should handle one empty input (original text empty)', () => { + const originalText = '' + const modifiedText = 'line1\nline2\nline3' + + const decorationInfo = getDecorationInfo(originalText, modifiedText) - const result = getLineLevelDiff(currentLines, emptyLines) + const expected: DecorationInfo = { + addedLines: [ + { type: 'added', lineNumber: 1, text: 'line2' } as AddedLineInfo, + { type: 'added', lineNumber: 2, text: 'line3' } as AddedLineInfo, + ], + modifiedLines: [ + { + type: 'modified', + lineNumber: 0, + oldText: '', + newText: 'line1', + changes: [ + { + type: 'insert', + range: expect.anything(), + text: 'line1', + }, + ], + }, + ], + removedLines: [], + unchangedLines: [], + } - expect(result.modifiedLines).toEqual([]) - expect(result.addedLines).toEqual([]) - expect(result.removedLines).toEqual([0, 1, 2]) - expect(result.unchangedLines).toEqual([]) + expect(decorationInfo).toEqual(expected) + }) + + it('should handle one empty input (modified text empty)', () => { + const originalText = 'line1\nline2\nline3' + const modifiedText = '' - const result2 = getLineLevelDiff(emptyLines, currentLines) + const decorationInfo = getDecorationInfo(originalText, modifiedText) - expect(result2.modifiedLines).toEqual([]) - expect(result2.addedLines).toEqual([0, 1, 2]) - expect(result2.removedLines).toEqual([]) - expect(result2.unchangedLines).toEqual([]) + const expected: DecorationInfo = { + addedLines: [], + removedLines: [ + { type: 'removed', lineNumber: 1, text: 'line2' }, + { type: 'removed', lineNumber: 2, text: 'line3' }, + ], + modifiedLines: [ + { + type: 'modified', + lineNumber: 0, + oldText: 'line1', + newText: '', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'line1', + }, + ], + }, + ], + unchangedLines: [], + } + + expect(decorationInfo).toEqual(expected) }) it('should handle arrays with only whitespace differences', () => { - const currentLines = [' line1', 'line2 ', ' line3 '] - const predictedLines = ['line1', 'line2', 'line3'] - - const result = getLineLevelDiff(currentLines, predictedLines) - - expect(result.modifiedLines).toEqual([ - { oldNumber: 0, newNumber: 0 }, - { oldNumber: 1, newNumber: 1 }, - { oldNumber: 2, newNumber: 2 }, - ]) - expect(result.addedLines).toEqual([]) - expect(result.removedLines).toEqual([]) - expect(result.unchangedLines).toEqual([]) + const originalText = ' line1\nline2 \n line3 ' + const modifiedText = 'line1\nline2\nline3' + + const decorationInfo = getDecorationInfo(originalText, modifiedText) + + const expected: DecorationInfo = { + addedLines: [], + removedLines: [], + modifiedLines: [ + { + type: 'modified', + lineNumber: 0, + oldText: ' line1', + newText: 'line1', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: ' ', + }, + ], + }, + { + type: 'modified', + lineNumber: 1, + oldText: 'line2 ', + newText: 'line2', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: ' ', + }, + ], + }, + { + type: 'modified', + lineNumber: 2, + oldText: ' line3 ', + newText: 'line3', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: ' ', + }, + { + type: 'delete', + range: expect.anything(), + text: ' ', + }, + ], + }, + ], + unchangedLines: [], + } + + expect(decorationInfo).toEqual(expected) + }) + + it('should merge adjacent insertions and deletions into separate changes', () => { + const originalText = 'const value = 123' + const modifiedText = 'const span = trace.getActiveTrace()' + + const decorationInfo = getDecorationInfo(originalText, modifiedText) + + const expected: DecorationInfo = { + addedLines: [], + removedLines: [], + modifiedLines: [ + { + type: 'modified', + lineNumber: 0, + oldText: 'const value = 123', + newText: 'const span = trace.getActiveTrace()', + changes: [ + { + type: 'delete', + range: expect.anything(), + text: 'value', + }, + { + type: 'insert', + range: expect.anything(), + text: 'span', + }, + { + type: 'delete', + range: expect.anything(), + text: '123', + }, + { + type: 'insert', + range: expect.anything(), + text: 'trace.getActiveTrace()', + }, + ], + } as ModifiedLineInfo, + ], + unchangedLines: [], + } + + expect(decorationInfo).toEqual(expected) }) }) diff --git a/vscode/src/autoedits/renderer/diff-utils.ts b/vscode/src/autoedits/renderer/diff-utils.ts index d6fc3a58283e..728ea652a5b2 100644 --- a/vscode/src/autoedits/renderer/diff-utils.ts +++ b/vscode/src/autoedits/renderer/diff-utils.ts @@ -1,241 +1,263 @@ import { diff } from 'fast-myers-diff' -import { range, zip } from 'lodash' -import { lines } from '../../completions/text-processing' -import { - type DecorationInformation, - type DecorationLineInformation, - DecorationLineType, -} from './decorators/base' +import * as vscode from 'vscode' -/** - * Represents a line that was preserved (either modified or unchanged) between two versions of text, - * tracking both its line number in the before and after states - */ -export interface PreservedLine { - /** The line number in the original text */ - oldNumber: number - /** The line number in the modified text */ - newNumber: number -} +import { getNewLineChar } from '../../completions/text-processing' +import type { DecorationInfo, DecorationLineInfo, LineChange, ModifiedLineInfo } from './decorators/base' /** - * Represents the ranges of text that were modified between two versions - * Replace the text between from1 and to1 in the original text with the text between from2 and to2 in the modified text + * Generates decoration information by computing the differences between two texts. + * + * @param originalText The original text content. + * @param modifiedText The modified text content. + * @returns Decoration information representing the differences. */ -export interface ModifiedRange { - /** The start position in the original text */ - from1: number - /** The end position in the original text */ - to1: number - /** The start position in the modified text */ - from2: number - /** The end position in the modified text */ - to2: number +export function getDecorationInfo(originalText: string, modifiedText: string): DecorationInfo { + const newLineChar = getNewLineChar(originalText) + const originalLines = originalText.split(newLineChar) + const modifiedLines = modifiedText.split(newLineChar) + + const lineInfos = computeDiffOperations(originalLines, modifiedLines) + + const decorationInfo: DecorationInfo = { + modifiedLines: [], + removedLines: [], + addedLines: [], + unchangedLines: [], + } + + for (const lineInfo of lineInfos) { + switch (lineInfo.type) { + case 'unchanged': + decorationInfo.unchangedLines.push(lineInfo) + break + case 'added': + decorationInfo.addedLines.push(lineInfo) + break + case 'removed': + decorationInfo.removedLines.push(lineInfo) + break + case 'modified': + decorationInfo.modifiedLines.push(lineInfo as ModifiedLineInfo) + break + } + } + + return decorationInfo } /** - * Represents the differences between two texts at a line level, - * tracking modified, added and removed lines + * Computes the diff operations between two arrays of lines. */ -interface LineLevelDiff { - /** Lines that were modified between versions */ - modifiedLines: PreservedLine[] - /** Line numbers that were added in the new version */ - addedLines: number[] - /** Line numbers that were removed from the original version */ - removedLines: number[] - /** Line numbers that were unchanged between the original and modified versions */ - unchangedLines: PreservedLine[] -} +function computeDiffOperations(originalLines: string[], modifiedLines: string[]): DecorationLineInfo[] { + // Compute the list of diff chunks between the original and modified lines. + // Each diff chunk is a tuple representing the range of changes: + // [originalStart, originalEnd, modifiedStart, modifiedEnd] + const diffs = diff(originalLines, modifiedLines) -export function getDecorationInformation( - currentFileText: string, - predictedFileText: string -): DecorationInformation { - const oldLines = lines(currentFileText) - const newLines = lines(predictedFileText) - const { modifiedLines, removedLines, addedLines, unchangedLines } = getLineLevelDiff( - oldLines, - newLines - ) - const oldLinesChunks = oldLines.map(line => splitLineIntoChunks(line)) - const newLinesChunks = newLines.map(line => splitLineIntoChunks(line)) - - const decorationLineInformation: DecorationLineInformation[] = [] - for (const line of removedLines) { - decorationLineInformation.push(getDecorationInformationForRemovedLine(line, oldLines[line])) - } - for (const line of addedLines) { - decorationLineInformation.push(getDecorationInformationForAddedLine(line, newLines[line])) + // Initialize an array to collect information about each line and its change type. + const lineInfos: DecorationLineInfo[] = [] + + // Initialize indices to keep track of the current position in the original and modified lines. + let originalIndex = 0 // Current index in originalLines + let modifiedIndex = 0 // Current index in modifiedLines + + // Iterate over each diff chunk to process changes. + for (const [originalStart, originalEnd, modifiedStart, modifiedEnd] of diffs) { + // Process any unchanged lines before the current diff chunk begins. + // These are lines that are identical in both files up to the point of the change. + while (originalIndex < originalStart && modifiedIndex < modifiedStart) { + lineInfos.push({ + type: 'unchanged', + lineNumber: modifiedIndex, + text: modifiedLines[modifiedIndex], + }) + originalIndex++ + modifiedIndex++ + } + + // Calculate the number of deletions and insertions in the current diff chunk. + const numDeletions = originalEnd - originalStart // Number of lines deleted from originalLines + const numInsertions = modifiedEnd - modifiedStart // Number of lines added to modifiedLines + + // The minimum between deletions and insertions represents replacements (modified lines). + // These are lines where content has changed but positions remain the same. + const numReplacements = Math.min(numDeletions, numInsertions) + + // Process replacements: lines that have been modified. + for (let i = 0; i < numReplacements; i++) { + const modifiedLineInfo = createModifiedLineInfo({ + modifiedLineNumber: modifiedStart + i, + originalText: originalLines[originalStart + i], + modifiedText: modifiedLines[modifiedStart + i], + }) + lineInfos.push(modifiedLineInfo) + } + + // Process deletions: lines that were removed from the original text. + for (let i = numReplacements; i < numDeletions; i++) { + lineInfos.push({ + type: 'removed', + lineNumber: originalStart + i, // Line number in the originalLines + text: originalLines[originalStart + i], + }) + } + + // Process insertions: lines that were added to the modified text. + for (let i = numReplacements; i < numInsertions; i++) { + lineInfos.push({ + type: 'added', + lineNumber: modifiedStart + i, // Line number in the modifiedLines + text: modifiedLines[modifiedStart + i], + }) + } + + // Update the indices to the end of the current diff chunk. + originalIndex = originalEnd + modifiedIndex = modifiedEnd } - for (const modifiedLine of modifiedLines) { - const modifiedRanges = getModifiedRangesForLine( - oldLinesChunks[modifiedLine.oldNumber], - newLinesChunks[modifiedLine.newNumber] - ) - // Modified ranges are based on the chunks of the original text, which could be char level or word level - // Adjust the ranges to get the modified ranges in terms of the original text - const adjustedModifiedRanges = modifiedRanges.map(range => ({ - from1: getCharacterOffsetFromChunks(oldLinesChunks[modifiedLine.oldNumber], range.from1), - to1: getCharacterOffsetFromChunks(oldLinesChunks[modifiedLine.oldNumber], range.to1), - from2: getCharacterOffsetFromChunks(newLinesChunks[modifiedLine.newNumber], range.from2), - to2: getCharacterOffsetFromChunks(newLinesChunks[modifiedLine.newNumber], range.to2), - })) - - decorationLineInformation.push({ - lineType: DecorationLineType.Modified, - oldLineNumber: modifiedLine.oldNumber, - newLineNumber: modifiedLine.newNumber, - oldText: oldLines[modifiedLine.oldNumber], - newText: newLines[modifiedLine.newNumber], - modifiedRanges: adjustedModifiedRanges, + + // Process any remaining unchanged lines after the last diff chunk. + while (originalIndex < originalLines.length && modifiedIndex < modifiedLines.length) { + lineInfos.push({ + type: 'unchanged', + lineNumber: modifiedIndex, + text: modifiedLines[modifiedIndex], }) + originalIndex++ + modifiedIndex++ } - for (const unchangedLine of unchangedLines) { - decorationLineInformation.push( - getDecorationInformationForUnchangedLine( - unchangedLine.oldNumber, - unchangedLine.newNumber, - oldLines[unchangedLine.oldNumber] - ) - ) - } - return { - lines: decorationLineInformation, - oldLines, - newLines, - } -} -function getDecorationInformationForUnchangedLine( - oldLineNumber: number, - newLineNumber: number, - text: string -): DecorationLineInformation { - return { - lineType: DecorationLineType.Unchanged, - oldLineNumber, - newLineNumber, - oldText: text, - newText: text, - modifiedRanges: [], - } + return lineInfos } -function getDecorationInformationForAddedLine( - newLineNumber: number, - text: string -): DecorationLineInformation { - return { - lineType: DecorationLineType.Added, - oldLineNumber: null, - newLineNumber, - oldText: '', - newText: text, - modifiedRanges: [{ from1: 0, to1: 0, from2: 0, to2: text.length }], - } -} +/** + * Creates a ModifiedLineInfo object by computing insertions and deletions within a line. + */ +function createModifiedLineInfo({ + modifiedLineNumber, + originalText, + modifiedText, +}: { + modifiedLineNumber: number + originalText: string + modifiedText: string +}): ModifiedLineInfo { + const oldChunks = splitLineIntoChunks(originalText) + const newChunks = splitLineIntoChunks(modifiedText) + const lineChanges = computeLineChanges({ oldChunks, newChunks, lineNumber: modifiedLineNumber }) -function getDecorationInformationForRemovedLine( - oldLineNumber: number, - text: string -): DecorationLineInformation { return { - lineType: DecorationLineType.Removed, - oldLineNumber, - newLineNumber: null, - oldText: text, - newText: '', - modifiedRanges: [{ from1: 0, to1: text.length, from2: 0, to2: 0 }], + type: 'modified', + lineNumber: modifiedLineNumber, + oldText: originalText, + newText: modifiedText, + changes: lineChanges, } } -export function getLineLevelDiff(oldLines: string[], newLines: string[]): LineLevelDiff { - const modifiedLines: PreservedLine[] = [] - const addedLines: number[] = [] - const removedLines: number[] = [] - - const unchangedLinesOldLineNumbers: number[] = [] - const unchangedLinesNewLineNumbers: number[] = [] - let lastChangedOldLine = -1 // Dummy value to indicate the last changed old line - let lastChangedNewLine = -1 // Dummy value to indicate the last changed new line - - for (const [from1, to1, from2, to2] of diff(oldLines, newLines)) { - // Deleted or modify the lines from from1 to to1 - // Added or modify the lines from from2 to to2 - // Greedily match the lines min (to1 - from1, to2 - from2) as the modified lines and add the rest to removed or added - // todo (hitesh): Improve the logic to handle the cases when fully removed or added lines can be at the start - const minLength = Math.min(to1 - from1, to2 - from2) - for (let i = 0; i < minLength; i++) { - modifiedLines.push({ oldNumber: from1 + i, newNumber: from2 + i }) - } - if (to1 - from1 > minLength) { - removedLines.push(...range(from1 + minLength, to1)) +/** + * Computes insertions and deletions within a line. + */ +function computeLineChanges({ + oldChunks, + newChunks, + lineNumber, +}: { oldChunks: string[]; newChunks: string[]; lineNumber: number }): LineChange[] { + const changes: LineChange[] = [] + const chunkDiffs = diff(oldChunks, newChunks) + + let oldIndex = 0 + let newIndex = 0 + let oldOffset = 0 + let newOffset = 0 + + for (const [oldStart, oldEnd, newStart, newEnd] of chunkDiffs) { + // Process unchanged chunks before this diff + while (oldIndex < oldStart && newIndex < newStart) { + oldOffset += oldChunks[oldIndex].length + newOffset += newChunks[newIndex].length + oldIndex++ + newIndex++ } - if (to2 - from2 > minLength) { - addedLines.push(...range(from2 + minLength, to2)) + + // Process deletions from oldChunks + let deletionText = '' + const deletionStartOffset = oldOffset + for (let i = oldStart; i < oldEnd; i++) { + deletionText += oldChunks[i] + oldOffset += oldChunks[i].length + oldIndex++ } - if (from1 > lastChangedOldLine + 1) { - unchangedLinesOldLineNumbers.push(...range(lastChangedOldLine + 1, from1)) + if (deletionText) { + const deleteRange = new vscode.Range(lineNumber, deletionStartOffset, lineNumber, oldOffset) + // Merge adjacent deletions + const lastChange = changes[changes.length - 1] + if ( + lastChange && + lastChange.type === 'delete' && + lastChange.range.end.isEqual(deleteRange.start) + ) { + lastChange.text += deletionText + lastChange.range = new vscode.Range(lastChange.range.start, deleteRange.end) + } else { + changes.push({ + type: 'delete', + range: deleteRange, + text: deletionText, + }) + } } - if (from2 > lastChangedNewLine + 1) { - unchangedLinesNewLineNumbers.push(...range(lastChangedNewLine + 1, from2)) + + // Process insertions from newChunks + let insertionText = '' + const insertionStartOffset = newOffset + for (let i = newStart; i < newEnd; i++) { + insertionText += newChunks[i] + newOffset += newChunks[i].length + newIndex++ } - lastChangedOldLine = to1 - 1 - lastChangedNewLine = to2 - 1 - } - if (lastChangedOldLine + 1 < oldLines.length) { - unchangedLinesOldLineNumbers.push(...range(lastChangedOldLine + 1, oldLines.length)) - } - if (lastChangedNewLine + 1 < newLines.length) { - unchangedLinesNewLineNumbers.push(...range(lastChangedNewLine + 1, newLines.length)) - } - const unchangedLines: PreservedLine[] = [] - for (const [oldLineNumber, newLineNumber] of zip( - unchangedLinesOldLineNumbers, - unchangedLinesNewLineNumbers - )) { - if (oldLineNumber !== undefined && newLineNumber !== undefined) { - unchangedLines.push({ oldNumber: oldLineNumber, newNumber: newLineNumber }) + if (insertionText) { + const insertRange = new vscode.Range( + lineNumber, + insertionStartOffset, + lineNumber, + insertionStartOffset + insertionText.length + ) + // Merge adjacent insertions + const lastChange = changes[changes.length - 1] + if ( + lastChange && + lastChange.type === 'insert' && + lastChange.range.end.isEqual(insertRange.start) + ) { + lastChange.text += insertionText + lastChange.range = new vscode.Range(lastChange.range.start, insertRange.end) + } else { + changes.push({ + type: 'insert', + range: insertRange, + text: insertionText, + }) + } } } - return { - modifiedLines, - addedLines, - removedLines, - unchangedLines, - } -} -export function getModifiedRangesForLine(before: string[], after: string[]): ModifiedRange[] { - const modifiedRanges: ModifiedRange[] = [] - for (const [from1, to1, from2, to2] of diff(before, after)) { - modifiedRanges.push({ from1, to1, from2, to2 }) + // Process any remaining unchanged chunks after the last diff + while (oldIndex < oldChunks.length && newIndex < newChunks.length) { + oldOffset += oldChunks[oldIndex].length + newOffset += newChunks[newIndex].length + oldIndex++ + newIndex++ } - return modifiedRanges + + return changes } /** - * Checks if the changes between current and predicted text only consist of added lines + * Splits a line into chunks for fine-grained diffing. + * Uses word boundaries, spaces and non-alphanumeric characters for splitting. */ -export function isPureAddedLines(currentFileText: string, predictedFileText: string): boolean { - const currentLines = currentFileText.split('\n') - const predictedLines = predictedFileText.split('\n') - for (const [from1, to1, from2, to2] of diff(currentLines, predictedLines)) { - if (to2 - to1 > from2 - from1) { - return true - } - } - return false -} - export function splitLineIntoChunks(line: string): string[] { - // Strategy 1: Split line into chars - // return line.split('') - // Strategy 2: Split line into words seperated by punctuations, white space etc. - return line.split(/(?=[^a-zA-Z0-9])|(?<=[^a-zA-Z0-9])/) -} - -function getCharacterOffsetFromChunks(parts: string[], chunkIndex: number): number { - return parts.slice(0, chunkIndex).reduce((acc: number, str: string) => acc + str.length, 0) + // Split line into words, consecutive spaces and punctuation marks + return line.match(/(\w+|\s+|\W)/g) || [] } diff --git a/vscode/src/autoedits/renderer/manager.ts b/vscode/src/autoedits/renderer/manager.ts index 7717c1e52727..07a07a222d70 100644 --- a/vscode/src/autoedits/renderer/manager.ts +++ b/vscode/src/autoedits/renderer/manager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode' -import type { AutoeditsDecorator } from './decorators/base' -import { getDecorationInformation } from './diff-utils' + +import type { AutoEditsDecorator } from './decorators/base' +import { getDecorationInfo } from './diff-utils' /** * Represents a proposed text change in the editor. @@ -16,7 +17,7 @@ interface ProposedChange { prediction: string // The renderer responsible for decorating the proposed change - decorator: AutoeditsDecorator + decorator: AutoEditsDecorator } /** @@ -44,7 +45,7 @@ export class AutoEditsRendererManager implements vscode.Disposable { private activeEdit: ProposedChange | null = null private disposables: vscode.Disposable[] = [] - constructor(private createDecorator: (editor: vscode.TextEditor) => AutoeditsDecorator) { + constructor(private createDecorator: (editor: vscode.TextEditor) => AutoEditsDecorator) { this.disposables.push( vscode.commands.registerCommand('cody.supersuggest.accept', () => this.acceptEdit()), vscode.commands.registerCommand('cody.supersuggest.dismiss', () => this.dismissEdit()), @@ -63,22 +64,25 @@ export class AutoEditsRendererManager implements vscode.Disposable { return this.activeEdit !== null } - public async showEdit(options: AutoEditsManagerOptions): Promise { + public async showEdit({ + document, + range, + prediction, + currentFileText, + predictedFileText, + }: AutoEditsManagerOptions): Promise { await this.dismissEdit() const editor = vscode.window.activeTextEditor - if (!editor || options.document !== editor.document) { + if (!editor || document !== editor.document) { return } this.activeEdit = { - uri: options.document.uri.toString(), - range: options.range, - prediction: options.prediction, + uri: document.uri.toString(), + range: range, + prediction: prediction, decorator: this.createDecorator(editor), } - const decorationInformation = getDecorationInformation( - options.currentFileText, - options.predictedFileText - ) + const decorationInformation = getDecorationInfo(currentFileText, predictedFileText) this.activeEdit.decorator.setDecorations(decorationInformation) await vscode.commands.executeCommand('setContext', 'cody.supersuggest.active', true) } diff --git a/vscode/src/autoedits/renderer/renderer-testing.ts b/vscode/src/autoedits/renderer/renderer-testing.ts index f3d2548a9772..af569c17e3ec 100644 --- a/vscode/src/autoedits/renderer/renderer-testing.ts +++ b/vscode/src/autoedits/renderer/renderer-testing.ts @@ -1,6 +1,9 @@ import * as vscode from 'vscode' + +import { getNewLineChar } from '../../completions/text-processing' import { DefaultDecorator } from './decorators/default-decorator' -import { getDecorationInformation } from './diff-utils' +import { getDecorationInfo } from './diff-utils' + export function registerTestRenderCommand(): vscode.Disposable { return vscode.commands.registerCommand('cody.supersuggest.testExample', () => { const editor = vscode.window.activeTextEditor @@ -57,13 +60,14 @@ export function registerTestRenderCommand(): vscode.Disposable { const decorator = new DefaultDecorator(editor) const currentFileText = document.getText() // splice replacerText into currentFileText at replaceStartLine and replacenEndLine - const lines = currentFileText.split('\n') + const newLineChar = getNewLineChar(currentFileText) + const lines = currentFileText.split(newLineChar) const predictedFileText = [ ...lines.slice(0, replaceStartLine), replacerText, ...lines.slice(replaceEndLine + 1), - ].join('\n') - const decorationInformation = getDecorationInformation(currentFileText, predictedFileText) + ].join(newLineChar) + const decorationInformation = getDecorationInfo(currentFileText, predictedFileText) decorator.setDecorations(decorationInformation) const listener = vscode.window.onDidChangeTextEditorSelection(e => { diff --git a/vscode/src/autoedits/utils.ts b/vscode/src/autoedits/utils.ts index 89344c05e4e2..53687c2d3184 100644 --- a/vscode/src/autoedits/utils.ts +++ b/vscode/src/autoedits/utils.ts @@ -1,5 +1,6 @@ -import { lines } from '../completions/text-processing' -import { getLineLevelDiff } from './renderer/diff-utils' +import { getNewLineChar, lines } from '../completions/text-processing' + +import { getDecorationInfo } from './renderer/diff-utils' export function fixFirstLineIndentation(source: string, target: string): string { // Check the first line indentation of source string and replaces in target string. @@ -8,7 +9,7 @@ export function fixFirstLineIndentation(source: string, target: string): string const firstLineMatch = codeToRewriteLines[0].match(/^(\s*)/) const firstLineIndentation = firstLineMatch ? firstLineMatch[1] : '' completionLines[0] = firstLineIndentation + completionLines[0].trimStart() - const completion = completionLines.join('\n') + const completion = completionLines.join(getNewLineChar(source)) return completion } @@ -36,7 +37,7 @@ export function extractInlineCompletionFromRewrittenCode( const completion = predictionWithoutPrefix.slice(0, endIndex) const completionNumLines = lines(completion).length const completionWithSameLineSuffix = lines(predictionWithoutPrefix).slice(0, completionNumLines) - return completionWithSameLineSuffix.join('\n') + return completionWithSameLineSuffix.join(getNewLineChar(codeToRewritePrefix + codeToRewriteSuffix)) } export function trimExtraNewLineCharsFromSuggestion( @@ -57,26 +58,27 @@ function getNumberOfNewLineCharsAtSuffix(text: string): number { return match ? match[0].length : 0 } -export function isPredictedTextAlreadyInSuffix(params: { +export function isPredictedTextAlreadyInSuffix({ + codeToRewrite, + prediction, + suffix, +}: { codeToRewrite: string prediction: string suffix: string }): boolean { - const currentFileLines = lines(params.codeToRewrite) - const predictedFileLines = lines(params.prediction) - let { addedLines } = getLineLevelDiff(currentFileLines, predictedFileLines) + const { addedLines } = getDecorationInfo(codeToRewrite, prediction) + if (addedLines.length === 0) { return false } - addedLines = addedLines.sort((a, b) => a - b) - const minAddedLineIndex = addedLines[0] - const maxAddedLineIndex = addedLines[addedLines.length - 1] - const allAddedLines = predictedFileLines.slice(minAddedLineIndex, maxAddedLineIndex + 1) - const allAddedLinesText = allAddedLines.join('\n') - if (params.suffix.startsWith(allAddedLinesText)) { - return true - } - return false + + const allAddedLinesText = addedLines + .sort((a, b) => a.lineNumber - b.lineNumber) + .map(line => line.text) + .join(getNewLineChar(codeToRewrite)) + + return suffix.startsWith(allAddedLinesText) } /** diff --git a/vscode/src/completions/text-processing/utils.ts b/vscode/src/completions/text-processing/utils.ts index 60d162a00464..2285e930f26f 100644 --- a/vscode/src/completions/text-processing/utils.ts +++ b/vscode/src/completions/text-processing/utils.ts @@ -436,3 +436,8 @@ export function getSuffixAfterFirstNewline(suffix: PromptString): PromptString { export function removeLeadingEmptyLines(str: string): string { return str.replace(/^[\r\n]+/, '') } + +export function getNewLineChar(existingText: string) { + const match = existingText.match(/\r\n|\n/) + return match ? match[0] : '\n' +}