Path: blob/main/src/vs/editor/common/diff/rangeMapping.ts
3294 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 { groupAdjacentBy } from '../../../base/common/arrays.js';6import { assertFn, checkAdjacentItems } from '../../../base/common/assert.js';7import { BugIndicatingError } from '../../../base/common/errors.js';8import { LineRange } from '../core/ranges/lineRange.js';9import { Position } from '../core/position.js';10import { Range } from '../core/range.js';11import { TextReplacement, TextEdit } from '../core/edits/textEdit.js';12import { AbstractText } from '../core/text/abstractText.js';13import { IChange } from './legacyLinesDiffComputer.js';1415/**16* Maps a line range in the original text model to a line range in the modified text model.17*/18export class LineRangeMapping {19public static inverse(mapping: readonly LineRangeMapping[], originalLineCount: number, modifiedLineCount: number): LineRangeMapping[] {20const result: LineRangeMapping[] = [];21let lastOriginalEndLineNumber = 1;22let lastModifiedEndLineNumber = 1;2324for (const m of mapping) {25const r = new LineRangeMapping(26new LineRange(lastOriginalEndLineNumber, m.original.startLineNumber),27new LineRange(lastModifiedEndLineNumber, m.modified.startLineNumber),28);29if (!r.modified.isEmpty) {30result.push(r);31}32lastOriginalEndLineNumber = m.original.endLineNumberExclusive;33lastModifiedEndLineNumber = m.modified.endLineNumberExclusive;34}35const r = new LineRangeMapping(36new LineRange(lastOriginalEndLineNumber, originalLineCount + 1),37new LineRange(lastModifiedEndLineNumber, modifiedLineCount + 1),38);39if (!r.modified.isEmpty) {40result.push(r);41}42return result;43}4445public static clip(mapping: readonly LineRangeMapping[], originalRange: LineRange, modifiedRange: LineRange): LineRangeMapping[] {46const result: LineRangeMapping[] = [];47for (const m of mapping) {48const original = m.original.intersect(originalRange);49const modified = m.modified.intersect(modifiedRange);50if (original && !original.isEmpty && modified && !modified.isEmpty) {51result.push(new LineRangeMapping(original, modified));52}53}54return result;55}5657/**58* The line range in the original text model.59*/60public readonly original: LineRange;6162/**63* The line range in the modified text model.64*/65public readonly modified: LineRange;6667constructor(68originalRange: LineRange,69modifiedRange: LineRange70) {71this.original = originalRange;72this.modified = modifiedRange;73}747576public toString(): string {77return `{${this.original.toString()}->${this.modified.toString()}}`;78}7980public flip(): LineRangeMapping {81return new LineRangeMapping(this.modified, this.original);82}8384public join(other: LineRangeMapping): LineRangeMapping {85return new LineRangeMapping(86this.original.join(other.original),87this.modified.join(other.modified)88);89}9091public get changedLineCount() {92return Math.max(this.original.length, this.modified.length);93}9495/**96* This method assumes that the LineRangeMapping describes a valid diff!97* I.e. if one range is empty, the other range cannot be the entire document.98* It avoids various problems when the line range points to non-existing line-numbers.99*/100public toRangeMapping(): RangeMapping {101const origInclusiveRange = this.original.toInclusiveRange();102const modInclusiveRange = this.modified.toInclusiveRange();103if (origInclusiveRange && modInclusiveRange) {104return new RangeMapping(origInclusiveRange, modInclusiveRange);105} else if (this.original.startLineNumber === 1 || this.modified.startLineNumber === 1) {106if (!(this.modified.startLineNumber === 1 && this.original.startLineNumber === 1)) {107// If one line range starts at 1, the other one must start at 1 as well.108throw new BugIndicatingError('not a valid diff');109}110111// Because one range is empty and both ranges start at line 1, none of the ranges can cover all lines.112// Thus, `endLineNumberExclusive` is a valid line number.113return new RangeMapping(114new Range(this.original.startLineNumber, 1, this.original.endLineNumberExclusive, 1),115new Range(this.modified.startLineNumber, 1, this.modified.endLineNumberExclusive, 1),116);117} else {118// We can assume here that both startLineNumbers are greater than 1.119return new RangeMapping(120new Range(this.original.startLineNumber - 1, Number.MAX_SAFE_INTEGER, this.original.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER),121new Range(this.modified.startLineNumber - 1, Number.MAX_SAFE_INTEGER, this.modified.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER),122);123}124}125126/**127* This method assumes that the LineRangeMapping describes a valid diff!128* I.e. if one range is empty, the other range cannot be the entire document.129* It avoids various problems when the line range points to non-existing line-numbers.130*/131public toRangeMapping2(original: string[], modified: string[]): RangeMapping {132if (isValidLineNumber(this.original.endLineNumberExclusive, original)133&& isValidLineNumber(this.modified.endLineNumberExclusive, modified)) {134return new RangeMapping(135new Range(this.original.startLineNumber, 1, this.original.endLineNumberExclusive, 1),136new Range(this.modified.startLineNumber, 1, this.modified.endLineNumberExclusive, 1),137);138}139140if (!this.original.isEmpty && !this.modified.isEmpty) {141return new RangeMapping(142Range.fromPositions(143new Position(this.original.startLineNumber, 1),144normalizePosition(new Position(this.original.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER), original)145),146Range.fromPositions(147new Position(this.modified.startLineNumber, 1),148normalizePosition(new Position(this.modified.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER), modified)149),150);151}152153if (this.original.startLineNumber > 1 && this.modified.startLineNumber > 1) {154return new RangeMapping(155Range.fromPositions(156normalizePosition(new Position(this.original.startLineNumber - 1, Number.MAX_SAFE_INTEGER), original),157normalizePosition(new Position(this.original.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER), original)158),159Range.fromPositions(160normalizePosition(new Position(this.modified.startLineNumber - 1, Number.MAX_SAFE_INTEGER), modified),161normalizePosition(new Position(this.modified.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER), modified)162),163);164}165166// Situation now: one range is empty and one range touches the last line and one range starts at line 1.167// I don't think this can happen.168169throw new BugIndicatingError();170}171}172173function normalizePosition(position: Position, content: string[]): Position {174if (position.lineNumber < 1) {175return new Position(1, 1);176}177if (position.lineNumber > content.length) {178return new Position(content.length, content[content.length - 1].length + 1);179}180const line = content[position.lineNumber - 1];181if (position.column > line.length + 1) {182return new Position(position.lineNumber, line.length + 1);183}184return position;185}186187function isValidLineNumber(lineNumber: number, lines: string[]): boolean {188return lineNumber >= 1 && lineNumber <= lines.length;189}190191/**192* Maps a line range in the original text model to a line range in the modified text model.193* Also contains inner range mappings.194*/195export class DetailedLineRangeMapping extends LineRangeMapping {196public static toTextEdit(mapping: readonly DetailedLineRangeMapping[], modified: AbstractText): TextEdit {197const replacements: TextReplacement[] = [];198for (const m of mapping) {199for (const r of m.innerChanges ?? []) {200const replacement = r.toTextEdit(modified);201replacements.push(replacement);202}203}204return new TextEdit(replacements);205}206207public static fromRangeMappings(rangeMappings: RangeMapping[]): DetailedLineRangeMapping {208const originalRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.originalRange)));209const modifiedRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.modifiedRange)));210return new DetailedLineRangeMapping(originalRange, modifiedRange, rangeMappings);211}212213/**214* If inner changes have not been computed, this is set to undefined.215* Otherwise, it represents the character-level diff in this line range.216* The original range of each range mapping should be contained in the original line range (same for modified), exceptions are new-lines.217* Must not be an empty array.218*/219public readonly innerChanges: RangeMapping[] | undefined;220221constructor(222originalRange: LineRange,223modifiedRange: LineRange,224innerChanges: RangeMapping[] | undefined225) {226super(originalRange, modifiedRange);227this.innerChanges = innerChanges;228}229230public override flip(): DetailedLineRangeMapping {231return new DetailedLineRangeMapping(this.modified, this.original, this.innerChanges?.map(c => c.flip()));232}233234public withInnerChangesFromLineRanges(): DetailedLineRangeMapping {235return new DetailedLineRangeMapping(this.original, this.modified, [this.toRangeMapping()]);236}237}238239/**240* Maps a range in the original text model to a range in the modified text model.241*/242export class RangeMapping {243public static fromEdit(edit: TextEdit): RangeMapping[] {244const newRanges = edit.getNewRanges();245const result = edit.replacements.map((e, idx) => new RangeMapping(e.range, newRanges[idx]));246return result;247}248249public static fromEditJoin(edit: TextEdit): RangeMapping {250const newRanges = edit.getNewRanges();251const result = edit.replacements.map((e, idx) => new RangeMapping(e.range, newRanges[idx]));252return RangeMapping.join(result);253}254255public static join(rangeMappings: RangeMapping[]): RangeMapping {256if (rangeMappings.length === 0) {257throw new BugIndicatingError('Cannot join an empty list of range mappings');258}259let result = rangeMappings[0];260for (let i = 1; i < rangeMappings.length; i++) {261result = result.join(rangeMappings[i]);262}263return result;264}265266public static assertSorted(rangeMappings: RangeMapping[]): void {267for (let i = 1; i < rangeMappings.length; i++) {268const previous = rangeMappings[i - 1];269const current = rangeMappings[i];270if (!(271previous.originalRange.getEndPosition().isBeforeOrEqual(current.originalRange.getStartPosition())272&& previous.modifiedRange.getEndPosition().isBeforeOrEqual(current.modifiedRange.getStartPosition())273)) {274throw new BugIndicatingError('Range mappings must be sorted');275}276}277}278279/**280* The original range.281*/282readonly originalRange: Range;283284/**285* The modified range.286*/287readonly modifiedRange: Range;288289constructor(290originalRange: Range,291modifiedRange: Range292) {293this.originalRange = originalRange;294this.modifiedRange = modifiedRange;295}296297public toString(): string {298return `{${this.originalRange.toString()}->${this.modifiedRange.toString()}}`;299}300301public flip(): RangeMapping {302return new RangeMapping(this.modifiedRange, this.originalRange);303}304305/**306* Creates a single text edit that describes the change from the original to the modified text.307*/308public toTextEdit(modified: AbstractText): TextReplacement {309const newText = modified.getValueOfRange(this.modifiedRange);310return new TextReplacement(this.originalRange, newText);311}312313public join(other: RangeMapping): RangeMapping {314return new RangeMapping(315this.originalRange.plusRange(other.originalRange),316this.modifiedRange.plusRange(other.modifiedRange)317);318}319}320321export function lineRangeMappingFromRangeMappings(alignments: readonly RangeMapping[], originalLines: AbstractText, modifiedLines: AbstractText, dontAssertStartLine: boolean = false): DetailedLineRangeMapping[] {322const changes: DetailedLineRangeMapping[] = [];323for (const g of groupAdjacentBy(324alignments.map(a => getLineRangeMapping(a, originalLines, modifiedLines)),325(a1, a2) =>326a1.original.intersectsOrTouches(a2.original)327|| a1.modified.intersectsOrTouches(a2.modified)328)) {329const first = g[0];330const last = g[g.length - 1];331332changes.push(new DetailedLineRangeMapping(333first.original.join(last.original),334first.modified.join(last.modified),335g.map(a => a.innerChanges![0]),336));337}338339assertFn(() => {340if (!dontAssertStartLine && changes.length > 0) {341if (changes[0].modified.startLineNumber !== changes[0].original.startLineNumber) {342return false;343}344345if (modifiedLines.length.lineCount - changes[changes.length - 1].modified.endLineNumberExclusive !== originalLines.length.lineCount - changes[changes.length - 1].original.endLineNumberExclusive) {346return false;347}348}349return checkAdjacentItems(changes,350(m1, m2) => m2.original.startLineNumber - m1.original.endLineNumberExclusive === m2.modified.startLineNumber - m1.modified.endLineNumberExclusive &&351// There has to be an unchanged line in between (otherwise both diffs should have been joined)352m1.original.endLineNumberExclusive < m2.original.startLineNumber &&353m1.modified.endLineNumberExclusive < m2.modified.startLineNumber,354);355});356357return changes;358}359360export function getLineRangeMapping(rangeMapping: RangeMapping, originalLines: AbstractText, modifiedLines: AbstractText): DetailedLineRangeMapping {361let lineStartDelta = 0;362let lineEndDelta = 0;363364// rangeMapping describes the edit that replaces `rangeMapping.originalRange` with `newText := getText(modifiedLines, rangeMapping.modifiedRange)`.365366// original: ]xxx \n <- this line is not modified367// modified: ]xx \n368if (rangeMapping.modifiedRange.endColumn === 1 && rangeMapping.originalRange.endColumn === 1369&& rangeMapping.originalRange.startLineNumber + lineStartDelta <= rangeMapping.originalRange.endLineNumber370&& rangeMapping.modifiedRange.startLineNumber + lineStartDelta <= rangeMapping.modifiedRange.endLineNumber) {371// We can only do this if the range is not empty yet372lineEndDelta = -1;373}374375// original: xxx[ \n <- this line is not modified376// modified: xxx[ \n377if (rangeMapping.modifiedRange.startColumn - 1 >= modifiedLines.getLineLength(rangeMapping.modifiedRange.startLineNumber)378&& rangeMapping.originalRange.startColumn - 1 >= originalLines.getLineLength(rangeMapping.originalRange.startLineNumber)379&& rangeMapping.originalRange.startLineNumber <= rangeMapping.originalRange.endLineNumber + lineEndDelta380&& rangeMapping.modifiedRange.startLineNumber <= rangeMapping.modifiedRange.endLineNumber + lineEndDelta) {381// We can only do this if the range is not empty yet382lineStartDelta = 1;383}384385const originalLineRange = new LineRange(386rangeMapping.originalRange.startLineNumber + lineStartDelta,387rangeMapping.originalRange.endLineNumber + 1 + lineEndDelta388);389const modifiedLineRange = new LineRange(390rangeMapping.modifiedRange.startLineNumber + lineStartDelta,391rangeMapping.modifiedRange.endLineNumber + 1 + lineEndDelta392);393394return new DetailedLineRangeMapping(originalLineRange, modifiedLineRange, [rangeMapping]);395}396397export function lineRangeMappingFromChange(change: IChange): LineRangeMapping {398let originalRange: LineRange;399if (change.originalEndLineNumber === 0) {400// Insertion401originalRange = new LineRange(change.originalStartLineNumber + 1, change.originalStartLineNumber + 1);402} else {403originalRange = new LineRange(change.originalStartLineNumber, change.originalEndLineNumber + 1);404}405406let modifiedRange: LineRange;407if (change.modifiedEndLineNumber === 0) {408// Deletion409modifiedRange = new LineRange(change.modifiedStartLineNumber + 1, change.modifiedStartLineNumber + 1);410} else {411modifiedRange = new LineRange(change.modifiedStartLineNumber, change.modifiedEndLineNumber + 1);412}413414return new LineRangeMapping(originalRange, modifiedRange);415}416417418