Path: blob/main/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts
13399 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 { SingleEdits } from '../../../platform/inlineEdits/common/dataTypes/edit';6import { ILogger } from '../../../platform/log/common/logService';7import { ErrorUtils } from '../../../util/common/errors';8import { AnnotatedStringEdit, AnnotatedStringReplacement, IEditData, StringEdit, StringReplacement, VoidEditData } from '../../../util/vs/editor/common/core/edits/stringEdit';9import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';10import { StringText } from '../../../util/vs/editor/common/core/text/abstractText';11import { DefaultLinesDiffComputer } from '../../../util/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer';12import { ILinesDiffComputerOptions } from '../../../util/vs/editor/common/diff/linesDiffComputer';1314const TROUBLESHOOT_EDIT_CONSISTENCY = false;1516export const maxAgreementOffset = 10; // If the user's typing is more than this into the suggestion we consider it a miss.17export const maxImperfectAgreementLength = 5; // If the user's typing is longer than this and the suggestion is not a perfect match we consider it a miss.1819export interface NesRebaseConfigs {20/**21* When enabled, if the user's typed text is an editor auto-close pair22* (e.g. `()`, `[]`, `{}`, `<>`, `""`, `''`, ` `` `) and both characters23* appear in order in the suggestion's insert text, the rebase absorbs24* the typed pair instead of failing.25*/26readonly absorbSubsequenceTyping?: boolean;27/**28* When enabled, allows rebase to succeed when the user typed more text29* than the model predicted at the same position (reverse agreement).30* Model edits consumed by the user's typing are absorbed, and any31* unconsumed portion of subsequent model edits is offered as the32* rebased suggestion.33*/34readonly reverseAgreement?: boolean;35/**36* Maximum length (in characters) of an imperfect agreement that is still37* accepted during a strict rebase. When the base new-text is longer than38* this value and it does not start at the exact predicted offset, the39* rebase is considered a miss. Helper functions such as {@link tryRebase}40* use {@link maxImperfectAgreementLength} when `nesConfigs` is omitted,41* but explicit {@link NesRebaseConfigs} objects must provide this value.42*/43readonly maxImperfectAgreementLength: number;44}4546export class EditDataWithIndex implements IEditData<EditDataWithIndex> {47constructor(48public readonly index: number49) { }5051join(data: EditDataWithIndex): EditDataWithIndex | undefined {52if (this.index !== data.index) {53return undefined;54}55return this;56}57}5859export function tryRebase(originalDocument: string, editWindow: OffsetRange | undefined, originalEdits: readonly StringReplacement[], detailedEdits: AnnotatedStringReplacement<EditDataWithIndex>[][], userEditSince: StringEdit, currentDocumentContent: string, currentSelection: readonly OffsetRange[], resolution: 'strict' | 'lenient', logger: ILogger, nesConfigs: NesRebaseConfigs = { maxImperfectAgreementLength }): { rebasedEdit: StringReplacement; rebasedEditIndex: number }[] | 'outsideEditWindow' | 'rebaseFailed' | 'error' | 'inconsistentEdits' {60const start = Date.now();61try {62return _tryRebase(originalDocument, editWindow, originalEdits, detailedEdits, userEditSince, currentDocumentContent, currentSelection, resolution, logger, nesConfigs);63} catch (err) {64logger.trace(`Rebase error: ${ErrorUtils.toString(err)}`);65return 'error';66} finally {67logger.trace(`Rebase duration: ${Date.now() - start}ms`);68}69}7071function _tryRebase(originalDocument: string, editWindow: OffsetRange | undefined, originalEdits: readonly StringReplacement[], detailedEdits: AnnotatedStringReplacement<EditDataWithIndex>[][], userEditSinceOrig: StringEdit, currentDocumentContent: string, currentSelection: readonly OffsetRange[], resolution: 'strict' | 'lenient', logger: ILogger, nesConfigs: NesRebaseConfigs) {72if (!checkEditConsistency(originalDocument, userEditSinceOrig, currentDocumentContent, logger, true)) {73return 'inconsistentEdits';74}75const userEditSince = userEditSinceOrig.removeCommonSuffixAndPrefix(originalDocument);76const cursorRange = currentSelection[0];77if (editWindow && cursorRange) {78const updatedEditWindow = userEditSince.applyToOffsetRangeOrUndefined(editWindow);79if (!updatedEditWindow?.containsRange(cursorRange)) {80return 'outsideEditWindow';81}82}83if (detailedEdits.length < originalEdits.length) {84let intermediateDocument = originalDocument;85for (let index = 0; index < detailedEdits.length; index++) {86const edit = originalEdits[index];87intermediateDocument = StringEdit.single(edit).apply(intermediateDocument);88}89for (let index = detailedEdits.length; index < originalEdits.length; index++) {90const edit = originalEdits[index];91const editData = new EditDataWithIndex(index);92detailedEdits[index] = computeDiff(edit.replaceRange.substring(intermediateDocument), edit.newText, edit.replaceRange.start, editData, {93ignoreTrimWhitespace: false,94computeMoves: false,95extendToSubwords: true,96maxComputationTimeMs: 500,97}) || [new AnnotatedStringReplacement(edit.replaceRange, edit.newText, editData)];98intermediateDocument = StringEdit.single(edit).apply(intermediateDocument);99}100}101const diffedEdit = AnnotatedStringEdit.compose(detailedEdits.map(edits => AnnotatedStringEdit.create(edits)));102const rebasedEdit = tryRebaseEdits(originalDocument, diffedEdit, userEditSince, resolution, nesConfigs);103if (!rebasedEdit) {104return 'rebaseFailed';105}106const grouped = rebasedEdit.replacements.reduce((acc, item) => {107(acc[item.data.index] ||= []).push(item);108return acc;109}, [] as (AnnotatedStringReplacement<EditDataWithIndex>[] | undefined)[]);110const resultEdits: { rebasedEdit: StringReplacement; rebasedEditIndex: number }[] = [];111for (let index = 0; index < grouped.length; index++) {112const group = grouped[index];113if (!group) {114continue;115}116const range = OffsetRange.fromTo(group[0].replaceRange.start, group[group.length - 1].replaceRange.endExclusive);117const newText = group.map((edit, i, a) => {118if (i > 0) {119return currentDocumentContent.substring(a[i - 1].replaceRange.endExclusive, edit.replaceRange.start) + edit.newText;120} else {121return edit.newText;122}123}).join('');124const resultEdit = StringReplacement.replace(range, newText);125if (!resultEdit.removeCommonSuffixAndPrefix(currentDocumentContent).isEmpty) {126resultEdits.push({ rebasedEdit: resultEdit, rebasedEditIndex: index });127}128}129if (resolution === 'strict' && resultEdits.length > 0 && new SingleEdits(originalEdits).apply(originalDocument) !== StringEdit.create(resultEdits.map(r => r.rebasedEdit)).apply(currentDocumentContent)) {130logger.trace('Result consistency check failed');131return 'inconsistentEdits';132}133return resultEdits;134}135136export function checkEditConsistency(original: string, edit: StringEdit, current: string, logger: ILogger, enabled = TROUBLESHOOT_EDIT_CONSISTENCY) {137if (!enabled) {138return true;139}140const consistent = edit.apply(original) === current;141if (!consistent) {142logger.trace('Edit consistency check failed');143}144return consistent;145}146147export function tryRebaseStringEdits<T extends IEditData<T>>(content: string, ours: StringEdit, base: StringEdit, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs = { maxImperfectAgreementLength }): StringEdit | undefined {148return tryRebaseEdits(content, ours.mapData(r => new VoidEditData()), base, resolution, nesConfigs)?.toStringEdit();149}150151function tryRebaseEdits<T extends IEditData<T>>(content: string, ours: AnnotatedStringEdit<T>, baseOrig: StringEdit, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs): AnnotatedStringEdit<T> | undefined {152const base = baseOrig.removeCommonSuffixAndPrefix(content);153154const newEdits: AnnotatedStringReplacement<T>[] = [];155156let baseIdx = 0;157let ourIdx = 0;158let offset = 0;159160while (ourIdx < ours.replacements.length || baseIdx < base.replacements.length) {161// take the edit that starts first162const baseEdit = base.replacements[baseIdx];163const ourEdit = ours.replacements[ourIdx];164165if (!ourEdit) {166if (resolution === 'strict') {167// baseEdit does not match but interleaves168return undefined;169}170// We processed all our edits171break;172} else if (!baseEdit) {173// no more edits from base174newEdits.push(ourEdit.delta(offset));175ourIdx++;176} else {177let ourE = ourEdit;178if (!ourE.replaceRange.containsRange(baseEdit.replaceRange)) {179// Try to shift our edit to include the base edit.180if (ourE.replaceRange.start > baseEdit.replaceRange.start) {181// Expand our edit to the left to include the base edit.182const added = content.substring(baseEdit.replaceRange.start, ourE.replaceRange.start);183const updated = added + ourE.newText;184// Remove the same text from the right.185if (updated.endsWith(added)) {186ourE = new AnnotatedStringReplacement(187OffsetRange.fromTo(baseEdit.replaceRange.start, ourE.replaceRange.endExclusive - added.length),188updated.substring(0, updated.length - added.length),189ourE.data,190);191}192}193// Skipping the case where there is another edit for now because we might have to merge with it first.194else if (ourIdx === ours.replacements.length - 1 && ourE.replaceRange.endExclusive < baseEdit.replaceRange.endExclusive) {195// Expand our edit to the right to include the base edit.196const added = content.substring(ourE.replaceRange.endExclusive, baseEdit.replaceRange.endExclusive);197const updated = ourE.newText + added;198// Remove the same text from the left.199if (updated.startsWith(added)) {200ourE = new AnnotatedStringReplacement(201OffsetRange.fromTo(ourE.replaceRange.start + added.length, baseEdit.replaceRange.endExclusive),202updated.substring(added.length),203ourE.data,204);205}206}207}208if (ourE.replaceRange.intersectsOrTouches(baseEdit.replaceRange)) {209if (ourE.replaceRange.containsRange(baseEdit.replaceRange) && ourE.newText.length >= baseEdit.newText.length) {210let delta = 0;211let ourNewTextOffset = 0;212let baseE = baseEdit;213let previousBaseE: StringReplacement | undefined;214while (baseE && ourE.replaceRange.containsRange(baseE.replaceRange)) {215ourNewTextOffset = agreementIndexOf(content, ourE, baseE, previousBaseE, ourNewTextOffset, resolution, nesConfigs);216if (ourNewTextOffset === -1) {217// Conflicting218return undefined;219}220delta += baseE.newText.length - baseE.replaceRange.length;221previousBaseE = baseE;222baseE = base.replacements[++baseIdx];223}224newEdits.push(new AnnotatedStringReplacement(225new OffsetRange(ourE.replaceRange.start + offset, ourE.replaceRange.endExclusive + offset + delta),226ourE.newText,227ourE.data,228));229ourIdx++;230offset += delta;231} else if (nesConfigs.reverseAgreement && ourEdit.replaceRange.equals(baseEdit.replaceRange)) {232// Reverse agreement: user's edit (base) covers model's edit (ours)233// at the same range. The user typed more than the model predicted.234// Use ourEdit (pre-shift) to avoid false matches from shift alignment.235// Iterate over consecutive our-edits consumed by this base edit.236let baseNewTextOffset = 0;237let previousOurE: AnnotatedStringReplacement<T> | undefined;238239while (ourIdx < ours.replacements.length && baseEdit.replaceRange.containsRange(ours.replacements[ourIdx].replaceRange)) {240const curOurE = ours.replacements[ourIdx];241242// Account for gap content between previous our-edit end and current our-edit start243const gapStart = previousOurE ? previousOurE.replaceRange.endExclusive : baseEdit.replaceRange.start;244const gapText = gapStart < curOurE.replaceRange.start ? content.substring(gapStart, curOurE.replaceRange.start) : '';245const effectiveText = gapText + curOurE.newText;246247// Try full consumption: model text found entirely within user text248const j = baseEdit.newText.indexOf(effectiveText, baseNewTextOffset);249const strictRejected = j !== -1 && resolution === 'strict' && (250j - baseNewTextOffset > maxAgreementOffset ||251(j - baseNewTextOffset > 0 && effectiveText.length > nesConfigs.maxImperfectAgreementLength)252);253254if (j !== -1 && !strictRejected) {255// Full consumption — model edit absorbed by user typing256baseNewTextOffset = j + effectiveText.length;257previousOurE = curOurE;258ourIdx++;259continue;260}261262// Try partial consumption: remaining user text is a prefix of model text263const remainingBase = baseEdit.newText.substring(baseNewTextOffset);264if (remainingBase.length > 0 && effectiveText.startsWith(remainingBase)) {265const consumedFromNewText = Math.max(0, remainingBase.length - gapText.length);266const unconsumedNewText = curOurE.newText.substring(consumedFromNewText);267if (unconsumedNewText.length > 0) {268newEdits.push(new AnnotatedStringReplacement(269OffsetRange.emptyAt(baseEdit.replaceRange.start + offset + baseEdit.newText.length),270unconsumedNewText,271curOurE.data,272));273}274baseNewTextOffset = baseEdit.newText.length;275previousOurE = curOurE;276ourIdx++;277break;278}279280// Conflicting281return undefined;282}283284// Verify trailing gap in strict mode: any original content between the285// last consumed our-edit and the end of the base range must be preserved.286// Remaining user text beyond the gap is the user's own typing and is fine.287if (baseNewTextOffset < baseEdit.newText.length && resolution === 'strict') {288const lastOurEnd = previousOurE ? previousOurE.replaceRange.endExclusive : baseEdit.replaceRange.start;289const trailingGap = content.substring(lastOurEnd, baseEdit.replaceRange.endExclusive);290if (trailingGap.length > 0) {291const remainingBase = baseEdit.newText.substring(baseNewTextOffset);292if (!remainingBase.startsWith(trailingGap)) {293return undefined;294}295}296}297298baseIdx++;299offset += baseEdit.newText.length - baseEdit.replaceRange.length;300} else {301// Conflicting302return undefined;303}304} else if (ourEdit.replaceRange.start < baseEdit.replaceRange.start) {305// Our edit starts first306newEdits.push(new AnnotatedStringReplacement(307ourEdit.replaceRange.delta(offset),308ourEdit.newText,309ourEdit.data,310));311ourIdx++;312} else {313if (resolution === 'strict') {314// baseEdit does not match but interleaves315return undefined;316}317baseIdx++;318offset += baseEdit.newText.length - baseEdit.replaceRange.length;319}320}321}322323return AnnotatedStringEdit.create(newEdits);324}325326/** Returns true if every character of `typed` appears in `suggestion` in order (subsequence match). */327function isSubsequenceOf(typed: string, suggestion: string): boolean {328let si = 0;329for (let ti = 0; ti < typed.length; ti++) {330const idx = suggestion.indexOf(typed[ti], si);331if (idx === -1) {332return false;333}334si = idx + 1;335}336return true;337}338339const autoClosePairs = new Set(['()', '[]', '{}', '<>', '""', `''`, '``']);340341/** Returns true if `text` is an editor auto-close pair such as `()`, `[]`, `{}`, etc. */342function isAutoClosePair(text: string): boolean {343return autoClosePairs.has(text);344}345346function agreementIndexOf<T extends IEditData<T>>(content: string, ourE: AnnotatedStringReplacement<T>, baseE: StringReplacement, previousBaseE: StringReplacement | undefined, ourNewTextOffset: number, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs) {347const originalBaseNewText = baseE.newText;348const minStart = previousBaseE ? previousBaseE.replaceRange.endExclusive : ourE.replaceRange.start;349if (minStart < baseE.replaceRange.start) {350baseE = new StringReplacement(351OffsetRange.fromTo(minStart, baseE.replaceRange.endExclusive),352content.substring(minStart, baseE.replaceRange.start) + baseE.newText353);354}355const j = ourE.newText.indexOf(baseE.newText, ourNewTextOffset);356const strictRejected = j !== -1 && resolution === 'strict' && (357j > maxAgreementOffset ||358(j > 0 && baseE.newText.length > nesConfigs.maxImperfectAgreementLength)359);360if (j !== -1 && !strictRejected) {361return j + baseE.newText.length;362}363// User typed text not found in suggestion (or rejected by strict limits) — absorb if it aligns as a subsequence.364// Guard: restrict to known editor auto-close pairs via isAutoClosePair(...) (effectively 2-character pairs)365// to avoid expensive matching on long pastes and to stay consistent with the existing strict safeguards.366if (nesConfigs.absorbSubsequenceTyping && isAutoClosePair(originalBaseNewText) && isSubsequenceOf(originalBaseNewText, ourE.newText.substring(ourNewTextOffset))) {367return ourNewTextOffset;368}369return -1;370}371372function computeDiff(original: string, modified: string, offset: number, editData: EditDataWithIndex, options: ILinesDiffComputerOptions): AnnotatedStringReplacement<EditDataWithIndex>[] | undefined {373const originalLines = original.split(/\r\n|\r|\n/);374const modifiedLines = modified.split(/\r\n|\r|\n/);375const diffComputer = new DefaultLinesDiffComputer();376const result = diffComputer.computeDiff(originalLines, modifiedLines, options);377if (result.hitTimeout) {378return undefined;379}380381const originalText = new StringText(original);382const modifiedText = new StringText(modified);383return result.changes.map(change => (change.innerChanges || []).map(innerChange => {384const range = originalText.getTransformer().getOffsetRange(innerChange.originalRange);385const newText = modifiedText.getValueOfRange(innerChange.modifiedRange);386return new AnnotatedStringReplacement(range.delta(offset), newText, editData);387})).flat();388}389390391