Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/common/diff/rangeMapping.ts
3294 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { groupAdjacentBy } from '../../../base/common/arrays.js';
7
import { assertFn, checkAdjacentItems } from '../../../base/common/assert.js';
8
import { BugIndicatingError } from '../../../base/common/errors.js';
9
import { LineRange } from '../core/ranges/lineRange.js';
10
import { Position } from '../core/position.js';
11
import { Range } from '../core/range.js';
12
import { TextReplacement, TextEdit } from '../core/edits/textEdit.js';
13
import { AbstractText } from '../core/text/abstractText.js';
14
import { IChange } from './legacyLinesDiffComputer.js';
15
16
/**
17
* Maps a line range in the original text model to a line range in the modified text model.
18
*/
19
export class LineRangeMapping {
20
public static inverse(mapping: readonly LineRangeMapping[], originalLineCount: number, modifiedLineCount: number): LineRangeMapping[] {
21
const result: LineRangeMapping[] = [];
22
let lastOriginalEndLineNumber = 1;
23
let lastModifiedEndLineNumber = 1;
24
25
for (const m of mapping) {
26
const r = new LineRangeMapping(
27
new LineRange(lastOriginalEndLineNumber, m.original.startLineNumber),
28
new LineRange(lastModifiedEndLineNumber, m.modified.startLineNumber),
29
);
30
if (!r.modified.isEmpty) {
31
result.push(r);
32
}
33
lastOriginalEndLineNumber = m.original.endLineNumberExclusive;
34
lastModifiedEndLineNumber = m.modified.endLineNumberExclusive;
35
}
36
const r = new LineRangeMapping(
37
new LineRange(lastOriginalEndLineNumber, originalLineCount + 1),
38
new LineRange(lastModifiedEndLineNumber, modifiedLineCount + 1),
39
);
40
if (!r.modified.isEmpty) {
41
result.push(r);
42
}
43
return result;
44
}
45
46
public static clip(mapping: readonly LineRangeMapping[], originalRange: LineRange, modifiedRange: LineRange): LineRangeMapping[] {
47
const result: LineRangeMapping[] = [];
48
for (const m of mapping) {
49
const original = m.original.intersect(originalRange);
50
const modified = m.modified.intersect(modifiedRange);
51
if (original && !original.isEmpty && modified && !modified.isEmpty) {
52
result.push(new LineRangeMapping(original, modified));
53
}
54
}
55
return result;
56
}
57
58
/**
59
* The line range in the original text model.
60
*/
61
public readonly original: LineRange;
62
63
/**
64
* The line range in the modified text model.
65
*/
66
public readonly modified: LineRange;
67
68
constructor(
69
originalRange: LineRange,
70
modifiedRange: LineRange
71
) {
72
this.original = originalRange;
73
this.modified = modifiedRange;
74
}
75
76
77
public toString(): string {
78
return `{${this.original.toString()}->${this.modified.toString()}}`;
79
}
80
81
public flip(): LineRangeMapping {
82
return new LineRangeMapping(this.modified, this.original);
83
}
84
85
public join(other: LineRangeMapping): LineRangeMapping {
86
return new LineRangeMapping(
87
this.original.join(other.original),
88
this.modified.join(other.modified)
89
);
90
}
91
92
public get changedLineCount() {
93
return Math.max(this.original.length, this.modified.length);
94
}
95
96
/**
97
* This method assumes that the LineRangeMapping describes a valid diff!
98
* I.e. if one range is empty, the other range cannot be the entire document.
99
* It avoids various problems when the line range points to non-existing line-numbers.
100
*/
101
public toRangeMapping(): RangeMapping {
102
const origInclusiveRange = this.original.toInclusiveRange();
103
const modInclusiveRange = this.modified.toInclusiveRange();
104
if (origInclusiveRange && modInclusiveRange) {
105
return new RangeMapping(origInclusiveRange, modInclusiveRange);
106
} else if (this.original.startLineNumber === 1 || this.modified.startLineNumber === 1) {
107
if (!(this.modified.startLineNumber === 1 && this.original.startLineNumber === 1)) {
108
// If one line range starts at 1, the other one must start at 1 as well.
109
throw new BugIndicatingError('not a valid diff');
110
}
111
112
// Because one range is empty and both ranges start at line 1, none of the ranges can cover all lines.
113
// Thus, `endLineNumberExclusive` is a valid line number.
114
return new RangeMapping(
115
new Range(this.original.startLineNumber, 1, this.original.endLineNumberExclusive, 1),
116
new Range(this.modified.startLineNumber, 1, this.modified.endLineNumberExclusive, 1),
117
);
118
} else {
119
// We can assume here that both startLineNumbers are greater than 1.
120
return new RangeMapping(
121
new Range(this.original.startLineNumber - 1, Number.MAX_SAFE_INTEGER, this.original.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER),
122
new Range(this.modified.startLineNumber - 1, Number.MAX_SAFE_INTEGER, this.modified.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER),
123
);
124
}
125
}
126
127
/**
128
* This method assumes that the LineRangeMapping describes a valid diff!
129
* I.e. if one range is empty, the other range cannot be the entire document.
130
* It avoids various problems when the line range points to non-existing line-numbers.
131
*/
132
public toRangeMapping2(original: string[], modified: string[]): RangeMapping {
133
if (isValidLineNumber(this.original.endLineNumberExclusive, original)
134
&& isValidLineNumber(this.modified.endLineNumberExclusive, modified)) {
135
return new RangeMapping(
136
new Range(this.original.startLineNumber, 1, this.original.endLineNumberExclusive, 1),
137
new Range(this.modified.startLineNumber, 1, this.modified.endLineNumberExclusive, 1),
138
);
139
}
140
141
if (!this.original.isEmpty && !this.modified.isEmpty) {
142
return new RangeMapping(
143
Range.fromPositions(
144
new Position(this.original.startLineNumber, 1),
145
normalizePosition(new Position(this.original.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER), original)
146
),
147
Range.fromPositions(
148
new Position(this.modified.startLineNumber, 1),
149
normalizePosition(new Position(this.modified.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER), modified)
150
),
151
);
152
}
153
154
if (this.original.startLineNumber > 1 && this.modified.startLineNumber > 1) {
155
return new RangeMapping(
156
Range.fromPositions(
157
normalizePosition(new Position(this.original.startLineNumber - 1, Number.MAX_SAFE_INTEGER), original),
158
normalizePosition(new Position(this.original.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER), original)
159
),
160
Range.fromPositions(
161
normalizePosition(new Position(this.modified.startLineNumber - 1, Number.MAX_SAFE_INTEGER), modified),
162
normalizePosition(new Position(this.modified.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER), modified)
163
),
164
);
165
}
166
167
// Situation now: one range is empty and one range touches the last line and one range starts at line 1.
168
// I don't think this can happen.
169
170
throw new BugIndicatingError();
171
}
172
}
173
174
function normalizePosition(position: Position, content: string[]): Position {
175
if (position.lineNumber < 1) {
176
return new Position(1, 1);
177
}
178
if (position.lineNumber > content.length) {
179
return new Position(content.length, content[content.length - 1].length + 1);
180
}
181
const line = content[position.lineNumber - 1];
182
if (position.column > line.length + 1) {
183
return new Position(position.lineNumber, line.length + 1);
184
}
185
return position;
186
}
187
188
function isValidLineNumber(lineNumber: number, lines: string[]): boolean {
189
return lineNumber >= 1 && lineNumber <= lines.length;
190
}
191
192
/**
193
* Maps a line range in the original text model to a line range in the modified text model.
194
* Also contains inner range mappings.
195
*/
196
export class DetailedLineRangeMapping extends LineRangeMapping {
197
public static toTextEdit(mapping: readonly DetailedLineRangeMapping[], modified: AbstractText): TextEdit {
198
const replacements: TextReplacement[] = [];
199
for (const m of mapping) {
200
for (const r of m.innerChanges ?? []) {
201
const replacement = r.toTextEdit(modified);
202
replacements.push(replacement);
203
}
204
}
205
return new TextEdit(replacements);
206
}
207
208
public static fromRangeMappings(rangeMappings: RangeMapping[]): DetailedLineRangeMapping {
209
const originalRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.originalRange)));
210
const modifiedRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.modifiedRange)));
211
return new DetailedLineRangeMapping(originalRange, modifiedRange, rangeMappings);
212
}
213
214
/**
215
* If inner changes have not been computed, this is set to undefined.
216
* Otherwise, it represents the character-level diff in this line range.
217
* The original range of each range mapping should be contained in the original line range (same for modified), exceptions are new-lines.
218
* Must not be an empty array.
219
*/
220
public readonly innerChanges: RangeMapping[] | undefined;
221
222
constructor(
223
originalRange: LineRange,
224
modifiedRange: LineRange,
225
innerChanges: RangeMapping[] | undefined
226
) {
227
super(originalRange, modifiedRange);
228
this.innerChanges = innerChanges;
229
}
230
231
public override flip(): DetailedLineRangeMapping {
232
return new DetailedLineRangeMapping(this.modified, this.original, this.innerChanges?.map(c => c.flip()));
233
}
234
235
public withInnerChangesFromLineRanges(): DetailedLineRangeMapping {
236
return new DetailedLineRangeMapping(this.original, this.modified, [this.toRangeMapping()]);
237
}
238
}
239
240
/**
241
* Maps a range in the original text model to a range in the modified text model.
242
*/
243
export class RangeMapping {
244
public static fromEdit(edit: TextEdit): RangeMapping[] {
245
const newRanges = edit.getNewRanges();
246
const result = edit.replacements.map((e, idx) => new RangeMapping(e.range, newRanges[idx]));
247
return result;
248
}
249
250
public static fromEditJoin(edit: TextEdit): RangeMapping {
251
const newRanges = edit.getNewRanges();
252
const result = edit.replacements.map((e, idx) => new RangeMapping(e.range, newRanges[idx]));
253
return RangeMapping.join(result);
254
}
255
256
public static join(rangeMappings: RangeMapping[]): RangeMapping {
257
if (rangeMappings.length === 0) {
258
throw new BugIndicatingError('Cannot join an empty list of range mappings');
259
}
260
let result = rangeMappings[0];
261
for (let i = 1; i < rangeMappings.length; i++) {
262
result = result.join(rangeMappings[i]);
263
}
264
return result;
265
}
266
267
public static assertSorted(rangeMappings: RangeMapping[]): void {
268
for (let i = 1; i < rangeMappings.length; i++) {
269
const previous = rangeMappings[i - 1];
270
const current = rangeMappings[i];
271
if (!(
272
previous.originalRange.getEndPosition().isBeforeOrEqual(current.originalRange.getStartPosition())
273
&& previous.modifiedRange.getEndPosition().isBeforeOrEqual(current.modifiedRange.getStartPosition())
274
)) {
275
throw new BugIndicatingError('Range mappings must be sorted');
276
}
277
}
278
}
279
280
/**
281
* The original range.
282
*/
283
readonly originalRange: Range;
284
285
/**
286
* The modified range.
287
*/
288
readonly modifiedRange: Range;
289
290
constructor(
291
originalRange: Range,
292
modifiedRange: Range
293
) {
294
this.originalRange = originalRange;
295
this.modifiedRange = modifiedRange;
296
}
297
298
public toString(): string {
299
return `{${this.originalRange.toString()}->${this.modifiedRange.toString()}}`;
300
}
301
302
public flip(): RangeMapping {
303
return new RangeMapping(this.modifiedRange, this.originalRange);
304
}
305
306
/**
307
* Creates a single text edit that describes the change from the original to the modified text.
308
*/
309
public toTextEdit(modified: AbstractText): TextReplacement {
310
const newText = modified.getValueOfRange(this.modifiedRange);
311
return new TextReplacement(this.originalRange, newText);
312
}
313
314
public join(other: RangeMapping): RangeMapping {
315
return new RangeMapping(
316
this.originalRange.plusRange(other.originalRange),
317
this.modifiedRange.plusRange(other.modifiedRange)
318
);
319
}
320
}
321
322
export function lineRangeMappingFromRangeMappings(alignments: readonly RangeMapping[], originalLines: AbstractText, modifiedLines: AbstractText, dontAssertStartLine: boolean = false): DetailedLineRangeMapping[] {
323
const changes: DetailedLineRangeMapping[] = [];
324
for (const g of groupAdjacentBy(
325
alignments.map(a => getLineRangeMapping(a, originalLines, modifiedLines)),
326
(a1, a2) =>
327
a1.original.intersectsOrTouches(a2.original)
328
|| a1.modified.intersectsOrTouches(a2.modified)
329
)) {
330
const first = g[0];
331
const last = g[g.length - 1];
332
333
changes.push(new DetailedLineRangeMapping(
334
first.original.join(last.original),
335
first.modified.join(last.modified),
336
g.map(a => a.innerChanges![0]),
337
));
338
}
339
340
assertFn(() => {
341
if (!dontAssertStartLine && changes.length > 0) {
342
if (changes[0].modified.startLineNumber !== changes[0].original.startLineNumber) {
343
return false;
344
}
345
346
if (modifiedLines.length.lineCount - changes[changes.length - 1].modified.endLineNumberExclusive !== originalLines.length.lineCount - changes[changes.length - 1].original.endLineNumberExclusive) {
347
return false;
348
}
349
}
350
return checkAdjacentItems(changes,
351
(m1, m2) => m2.original.startLineNumber - m1.original.endLineNumberExclusive === m2.modified.startLineNumber - m1.modified.endLineNumberExclusive &&
352
// There has to be an unchanged line in between (otherwise both diffs should have been joined)
353
m1.original.endLineNumberExclusive < m2.original.startLineNumber &&
354
m1.modified.endLineNumberExclusive < m2.modified.startLineNumber,
355
);
356
});
357
358
return changes;
359
}
360
361
export function getLineRangeMapping(rangeMapping: RangeMapping, originalLines: AbstractText, modifiedLines: AbstractText): DetailedLineRangeMapping {
362
let lineStartDelta = 0;
363
let lineEndDelta = 0;
364
365
// rangeMapping describes the edit that replaces `rangeMapping.originalRange` with `newText := getText(modifiedLines, rangeMapping.modifiedRange)`.
366
367
// original: ]xxx \n <- this line is not modified
368
// modified: ]xx \n
369
if (rangeMapping.modifiedRange.endColumn === 1 && rangeMapping.originalRange.endColumn === 1
370
&& rangeMapping.originalRange.startLineNumber + lineStartDelta <= rangeMapping.originalRange.endLineNumber
371
&& rangeMapping.modifiedRange.startLineNumber + lineStartDelta <= rangeMapping.modifiedRange.endLineNumber) {
372
// We can only do this if the range is not empty yet
373
lineEndDelta = -1;
374
}
375
376
// original: xxx[ \n <- this line is not modified
377
// modified: xxx[ \n
378
if (rangeMapping.modifiedRange.startColumn - 1 >= modifiedLines.getLineLength(rangeMapping.modifiedRange.startLineNumber)
379
&& rangeMapping.originalRange.startColumn - 1 >= originalLines.getLineLength(rangeMapping.originalRange.startLineNumber)
380
&& rangeMapping.originalRange.startLineNumber <= rangeMapping.originalRange.endLineNumber + lineEndDelta
381
&& rangeMapping.modifiedRange.startLineNumber <= rangeMapping.modifiedRange.endLineNumber + lineEndDelta) {
382
// We can only do this if the range is not empty yet
383
lineStartDelta = 1;
384
}
385
386
const originalLineRange = new LineRange(
387
rangeMapping.originalRange.startLineNumber + lineStartDelta,
388
rangeMapping.originalRange.endLineNumber + 1 + lineEndDelta
389
);
390
const modifiedLineRange = new LineRange(
391
rangeMapping.modifiedRange.startLineNumber + lineStartDelta,
392
rangeMapping.modifiedRange.endLineNumber + 1 + lineEndDelta
393
);
394
395
return new DetailedLineRangeMapping(originalLineRange, modifiedLineRange, [rangeMapping]);
396
}
397
398
export function lineRangeMappingFromChange(change: IChange): LineRangeMapping {
399
let originalRange: LineRange;
400
if (change.originalEndLineNumber === 0) {
401
// Insertion
402
originalRange = new LineRange(change.originalStartLineNumber + 1, change.originalStartLineNumber + 1);
403
} else {
404
originalRange = new LineRange(change.originalStartLineNumber, change.originalEndLineNumber + 1);
405
}
406
407
let modifiedRange: LineRange;
408
if (change.modifiedEndLineNumber === 0) {
409
// Deletion
410
modifiedRange = new LineRange(change.modifiedStartLineNumber + 1, change.modifiedStartLineNumber + 1);
411
} else {
412
modifiedRange = new LineRange(change.modifiedStartLineNumber, change.modifiedEndLineNumber + 1);
413
}
414
415
return new LineRangeMapping(originalRange, modifiedRange);
416
}
417
418