Introduces diffing infrastructure & experimental diffing algorithm. (#157646)

* Introduces diffing infrastructure & experimental diffing algorithm.

* Fixes CI

* Fixes unit test

* Fixes CI tests.
This commit is contained in:
Henning Dieterichs 2022-08-09 18:03:26 +02:00 committed by GitHub
parent 8bf82819fc
commit 516f0d1246
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1077 additions and 198 deletions

View file

@ -72,7 +72,7 @@ export interface ICommandHandler {
#include(vs/editor/common/core/editOperation): ISingleEditOperation
#include(vs/editor/common/core/wordHelper): IWordAtPosition
#includeAll(vs/editor/common/model): IScrollEvent
#include(vs/editor/common/diff/diffComputer): IChange, ICharChange, ILineChange
#include(vs/editor/common/diff/smartLinesDiffComputer): IChange, ICharChange, ILineChange
#include(vs/editor/common/core/dimension): IDimension
#includeAll(vs/editor/common/editorCommon): IScrollEvent
#includeAll(vs/editor/common/textModelEvents):

View file

@ -19,7 +19,7 @@ import { OverviewRulerZone } from 'vs/editor/common/viewModel/overviewZoneManage
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { IEditorWhitespace, IViewModel } from 'vs/editor/common/viewModel';
import { InjectedText } from 'vs/editor/common/modelLineProjectionData';
import { IDiffComputationResult, ILineChange } from 'vs/editor/common/diff/diffComputer';
import { ILineChange, IDiffComputationResult } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { IDimension } from 'vs/editor/common/core/dimension';
/**

View file

@ -10,12 +10,11 @@ import { SimpleWorkerClient, logOnceWebWorkerWarning, IWorkerClient } from 'vs/b
import { DefaultWorkerFactory } from 'vs/base/browser/defaultWorkerFactory';
import { Position } from 'vs/editor/common/core/position';
import { IRange, Range } from 'vs/editor/common/core/range';
import { IChange, IDiffComputationResult } from 'vs/editor/common/diff/diffComputer';
import { ITextModel } from 'vs/editor/common/model';
import * as languages from 'vs/editor/common/languages';
import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry';
import { EditorSimpleWorker } from 'vs/editor/common/services/editorSimpleWorker';
import { IEditorWorkerService, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker';
import { IDiffComputationResult, IEditorWorkerService, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker';
import { IModelService } from 'vs/editor/common/services/model';
import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration';
import { regExpFlags } from 'vs/base/common/strings';
@ -26,6 +25,8 @@ import { canceled } from 'vs/base/common/errors';
import { UnicodeHighlighterOptions } from 'vs/editor/common/services/unicodeTextModelHighlighter';
import { IEditorWorkerHost } from 'vs/editor/common/services/editorWorkerHost';
import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures';
import { IChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider';
/**
* Stop syncing a model to the worker if it was not needed for 1 min.
@ -94,8 +95,8 @@ export class EditorWorkerService extends Disposable implements IEditorWorkerServ
return this._workerManager.withWorker().then(client => client.computedUnicodeHighlights(uri, options, range));
}
public computeDiff(original: URI, modified: URI, ignoreTrimWhitespace: boolean, maxComputationTime: number): Promise<IDiffComputationResult | null> {
return this._workerManager.withWorker().then(client => client.computeDiff(original, modified, ignoreTrimWhitespace, maxComputationTime));
public computeDiff(original: URI, modified: URI, options: IDocumentDiffProviderOptions): Promise<IDiffComputationResult | null> {
return this._workerManager.withWorker().then(client => client.computeDiff(original, modified, options));
}
public canComputeDirtyDiff(original: URI, modified: URI): boolean {
@ -491,9 +492,9 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien
});
}
public computeDiff(original: URI, modified: URI, ignoreTrimWhitespace: boolean, maxComputationTime: number): Promise<IDiffComputationResult | null> {
public computeDiff(original: URI, modified: URI, options: IDocumentDiffProviderOptions): Promise<IDiffComputationResult | null> {
return this._withSyncedResources([original, modified], /* forceLargeModels */true).then(proxy => {
return proxy.computeDiff(original.toString(), modified.toString(), ignoreTrimWhitespace, maxComputationTime);
return proxy.computeDiff(original.toString(), modified.toString(), options);
});
}

View file

@ -53,10 +53,12 @@ import { IViewLineTokens } from 'vs/editor/common/tokens/lineTokens';
import { FontInfo } from 'vs/editor/common/config/fontInfo';
import { registerIcon } from 'vs/platform/theme/common/iconRegistry';
import { ILineBreaksComputer } from 'vs/editor/common/modelLineProjectionData';
import { IChange, ICharChange, IDiffComputationResult, ILineChange } from 'vs/editor/common/diff/diffComputer';
import { IChange, ICharChange, IDiffComputationResult, ILineChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration';
import { IDimension } from 'vs/editor/common/core/dimension';
import { isHighContrast } from 'vs/platform/theme/common/theme';
import { IDocumentDiffProvider } from 'vs/editor/common/diff/documentDiffProvider';
import { WorkerBasedDocumentDiffProvider } from 'vs/editor/browser/widget/workerBasedDocumentDiffProvider';
export interface IDiffCodeEditorWidgetOptions {
originalEditor?: ICodeEditorWidgetOptions;
@ -221,7 +223,7 @@ export class DiffEditorWidget extends Disposable implements editorBrowser.IDiffE
private readonly _updateDecorationsRunner: RunOnceScheduler;
private readonly _editorWorkerService: IEditorWorkerService;
private readonly _documentDiffProvider: IDocumentDiffProvider;
private readonly _contextKeyService: IContextKeyService;
private readonly _instantiationService: IInstantiationService;
private readonly _codeEditorService: ICodeEditorService;
@ -246,7 +248,7 @@ export class DiffEditorWidget extends Disposable implements editorBrowser.IDiffE
) {
super();
this._editorWorkerService = editorWorkerService;
this._documentDiffProvider = new WorkerBasedDocumentDiffProvider(editorWorkerService);
this._codeEditorService = codeEditorService;
this._contextKeyService = this._register(contextKeyService.createScoped(domElement));
this._instantiationService = instantiationService.createChild(new ServiceCollection([IContextKeyService, this._contextKeyService]));
@ -272,7 +274,8 @@ export class DiffEditorWidget extends Disposable implements editorBrowser.IDiffE
originalEditable: false,
diffCodeLens: false,
renderOverviewRuler: true,
diffWordWrap: 'inherit'
diffWordWrap: 'inherit',
diffAlgorithm: 'smart',
});
if (typeof options.isInEmbeddedEditor !== 'undefined') {
@ -751,7 +754,7 @@ export class DiffEditorWidget extends Disposable implements editorBrowser.IDiffE
this._options = newOptions;
const beginUpdateDecorations = (changed.ignoreTrimWhitespace || changed.renderIndicators || changed.renderMarginRevertIcon);
const beginUpdateDecorationsSoon = (this._isVisible && (changed.maxComputationTime || changed.maxFileSize));
const beginUpdateDecorationsSoon = (this._isVisible && (changed.maxComputationTime || changed.maxFileSize || changed.diffAlgorithm));
if (beginUpdateDecorations) {
this._beginUpdateDecorations();
@ -1111,13 +1114,65 @@ export class DiffEditorWidget extends Disposable implements editorBrowser.IDiffE
}
this._setState(editorBrowser.DiffEditorState.ComputingDiff);
this._editorWorkerService.computeDiff(currentOriginalModel.uri, currentModifiedModel.uri, this._options.ignoreTrimWhitespace, this._options.maxComputationTime).then((result) => {
this._documentDiffProvider.computeDiff(currentOriginalModel, currentModifiedModel, {
ignoreTrimWhitespace: this._options.ignoreTrimWhitespace,
maxComputationTime: this._options.maxComputationTime,
diffAlgorithm: this._options.diffAlgorithm,
}).then(result => {
if (currentToken === this._diffComputationToken
&& currentOriginalModel === this._originalEditor.getModel()
&& currentModifiedModel === this._modifiedEditor.getModel()
) {
this._setState(editorBrowser.DiffEditorState.DiffComputed);
this._diffComputationResult = result;
this._diffComputationResult = {
identical: result.identical,
quitEarly: result.quitEarly,
changes: result.changes.map(m => {
// TODO don't do this translation, but use the diff result directly
let originalStartLineNumber: number;
let originalEndLineNumber: number;
let modifiedStartLineNumber: number;
let modifiedEndLineNumber: number;
let innerChanges = m.innerChanges;
if (m.originalRange.isEmpty) {
// Insertion
originalStartLineNumber = m.originalRange.startLineNumber - 1;
originalEndLineNumber = 0;
innerChanges = undefined;
} else {
originalStartLineNumber = m.originalRange.startLineNumber;
originalEndLineNumber = m.originalRange.endLineNumberExclusive - 1;
}
if (m.modifiedRange.isEmpty) {
// Deletion
modifiedStartLineNumber = m.modifiedRange.startLineNumber - 1;
modifiedEndLineNumber = 0;
innerChanges = undefined;
} else {
modifiedStartLineNumber = m.modifiedRange.startLineNumber;
modifiedEndLineNumber = m.modifiedRange.endLineNumberExclusive - 1;
}
return {
originalStartLineNumber,
originalEndLineNumber,
modifiedStartLineNumber,
modifiedEndLineNumber,
charChanges: innerChanges?.map(m => ({
originalStartLineNumber: m.originalRange.startLineNumber,
originalStartColumn: m.originalRange.startColumn,
originalEndLineNumber: m.originalRange.endLineNumber,
originalEndColumn: m.originalRange.endColumn,
modifiedStartLineNumber: m.modifiedRange.startLineNumber,
modifiedStartColumn: m.modifiedRange.startColumn,
modifiedEndLineNumber: m.modifiedRange.endLineNumber,
modifiedEndColumn: m.modifiedRange.endColumn,
}))
};
})
};
this._updateDecorationsRunner.schedule();
this._onDidUpdateDiff.fire();
}
@ -2655,6 +2710,7 @@ function validateDiffEditorOptions(options: Readonly<IDiffEditorOptions>, defaul
diffCodeLens: validateBooleanOption(options.diffCodeLens, defaults.diffCodeLens),
renderOverviewRuler: validateBooleanOption(options.renderOverviewRuler, defaults.renderOverviewRuler),
diffWordWrap: validateDiffWordWrap(options.diffWordWrap, defaults.diffWordWrap),
diffAlgorithm: validateStringSetOption(options.diffAlgorithm, defaults.diffAlgorithm, ['smart', 'experimental']),
};
}
@ -2671,6 +2727,7 @@ function changedDiffEditorOptions(a: ValidDiffEditorBaseOptions, b: ValidDiffEdi
diffCodeLens: (a.diffCodeLens !== b.diffCodeLens),
renderOverviewRuler: (a.renderOverviewRuler !== b.renderOverviewRuler),
diffWordWrap: (a.diffWordWrap !== b.diffWordWrap),
diffAlgorithm: (a.diffAlgorithm !== b.diffAlgorithm),
};
}

View file

@ -10,7 +10,7 @@ import * as objects from 'vs/base/common/objects';
import { IDiffEditor } from 'vs/editor/browser/editorBrowser';
import { ICursorPositionChangedEvent } from 'vs/editor/common/cursorEvents';
import { Range } from 'vs/editor/common/core/range';
import { ILineChange } from 'vs/editor/common/diff/diffComputer';
import { ILineChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { ScrollType } from 'vs/editor/common/editorCommon';

View file

@ -34,7 +34,7 @@ import { Codicon } from 'vs/base/common/codicons';
import { registerIcon } from 'vs/platform/theme/common/iconRegistry';
import { ILanguageIdCodec } from 'vs/editor/common/languages';
import { ILanguageService } from 'vs/editor/common/languages/language';
import { ILineChange } from 'vs/editor/common/diff/diffComputer';
import { ILineChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
const DIFF_LINES_PADDING = 3;

View file

@ -0,0 +1,44 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { LineRange, LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer';
import { Range } from 'vs/editor/common/core/range';
import { IDocumentDiff, IDocumentDiffProvider, IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker';
import { ITextModel } from 'vs/editor/common/model';
export class WorkerBasedDocumentDiffProvider implements IDocumentDiffProvider {
constructor(
@IEditorWorkerService private readonly editorWorkerService: IEditorWorkerService,
) {
}
async computeDiff(original: ITextModel, modified: ITextModel, options: IDocumentDiffProviderOptions): Promise<IDocumentDiff> {
const result = await this.editorWorkerService.computeDiff(original.uri, modified.uri, options);
if (!result) {
throw new Error('no diff result available');
}
// Convert from space efficient JSON data to rich objects.
return {
identical: result.identical,
quitEarly: result.quitEarly,
changes: result.changes.map(
(c) =>
new LineRangeMapping(
new LineRange(c[0], c[1]),
new LineRange(c[2], c[3]),
c[4]?.map(
(c) =>
new RangeMapping(
new Range(c[0], c[1], c[2], c[3]),
new Range(c[4], c[5], c[6], c[7])
)
)
)
),
};
}
}

View file

@ -175,7 +175,16 @@ const editorConfiguration: IConfigurationNode = {
nls.localize('wordWrap.on', "Lines will wrap at the viewport width."),
nls.localize('wordWrap.inherit', "Lines will wrap according to the `#editor.wordWrap#` setting."),
]
}
},
'diffEditor.diffAlgorithm': {
type: 'string',
enum: ['smart', 'experimental'],
default: 'smart',
markdownEnumDescriptions: [
nls.localize('diffAlgorithm.smart', "Uses the default diffing algorithm."),
nls.localize('diffAlgorithm.experimental', "Uses an experimental diffing algorithm."),
]
},
}
};

View file

@ -733,6 +733,10 @@ export interface IDiffEditorBaseOptions {
* Control the wrapping of the diff editor.
*/
diffWordWrap?: 'off' | 'on' | 'inherit';
/**
* Diff Algorithm
*/
diffAlgorithm?: 'smart' | 'experimental';
}
/**

View file

@ -333,6 +333,13 @@ export class Range {
return Range.collapseToStart(this);
}
/**
* Moves the range by the given amount of lines.
*/
public delta(lineCount: number): Range {
return new Range(this.startLineNumber + lineCount, this.startColumn, this.endLineNumber + lineCount, this.endColumn);
}
/**
* Create a new empty range using this range's start position.
*/

View file

@ -0,0 +1,54 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Represents a synchronous diff algorithm. Should be executed in a worker.
*/
export interface IDiffAlgorithm {
compute(sequence1: ISequence, sequence2: ISequence): SequenceDiff[];
}
export class SequenceDiff {
constructor(
public readonly seq1Range: OffsetRange,
public readonly seq2Range: OffsetRange
) { }
}
/**
* Todo move this class to some top level utils.
*/
export class OffsetRange {
constructor(public readonly start: number, public readonly endExclusive: number) { }
get isEmpty(): boolean {
return this.start === this.endExclusive;
}
public delta(offset: number): OffsetRange {
return new OffsetRange(this.start + offset, this.endExclusive + offset);
}
public get length(): number {
return this.endExclusive - this.start;
}
}
export interface ISequence {
getElement(offset: number): number;
get length(): number;
}
export class SequenceFromIntArray implements ISequence {
constructor(private readonly arr: number[]) { }
getElement(offset: number): number {
return this.arr[offset];
}
get length(): number {
return this.arr.length;
}
}

View file

@ -0,0 +1,93 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IDiffAlgorithm, SequenceDiff, OffsetRange, ISequence } from 'vs/editor/common/diff/algorithms/diffAlgorithm';
import { Array2D } from 'vs/editor/common/diff/algorithms/utils';
/**
* A O(MN) diffing algorithm that supports a score function.
* The algorithm can be improved by processing the 2d array diagonally.
*/
export class DynamicProgrammingDiffing implements IDiffAlgorithm {
compute(sequence1: ISequence, sequence2: ISequence, equalityScore?: (offset1: number, offset2: number) => number): SequenceDiff[] {
/**
* lcsLengths.get(i, j): Length of the longest common subsequence of sequence1.substring(0, i + 1) and sequence2.substring(0, j + 1).
*/
const lcsLengths = new Array2D<number>(sequence1.length, sequence2.length);
const directions = new Array2D<number>(sequence1.length, sequence2.length);
const lengths = new Array2D<number>(sequence1.length, sequence2.length);
// ==== Initializing lcsLengths ====
for (let s1 = 0; s1 < sequence1.length; s1++) {
for (let s2 = 0; s2 < sequence2.length; s2++) {
const horizontalLen = s1 === 0 ? 0 : lcsLengths.get(s1 - 1, s2);
const verticalLen = s2 === 0 ? 0 : lcsLengths.get(s1, s2 - 1);
let extendedSeqScore: number;
if (sequence1.getElement(s1) === sequence2.getElement(s2)) {
if (s1 === 0 || s2 === 0) {
extendedSeqScore = 0;
} else {
extendedSeqScore = lcsLengths.get(s1 - 1, s2 - 1);
}
extendedSeqScore += (equalityScore ? equalityScore(s1, s2) : 1);
} else {
extendedSeqScore = -1;
}
const newValue = Math.max(horizontalLen, verticalLen, extendedSeqScore);
if (newValue === horizontalLen) {
lengths.set(s1, s2, 0);
directions.set(s1, s2, 1);
} else if (newValue === verticalLen) {
lengths.set(s1, s2, 0);
directions.set(s1, s2, 2);
} else {
const prevLen = s1 > 0 && s2 > 0 ? lengths.get(s1 - 1, s2 - 1) : 0;
lengths.set(s1, s2, prevLen + 1);
directions.set(s1, s2, 3);
}
lcsLengths.set(s1, s2, newValue);
}
}
// ==== Backtracking ====
const result: SequenceDiff[] = [];
let lastAligningPosS1: number = sequence1.length;
let lastAligningPosS2: number = sequence2.length;
function reportDecreasingAligningPositions(s1: number, s2: number): void {
if (s1 + 1 !== lastAligningPosS1 || s2 + 1 !== lastAligningPosS2) {
result.push(new SequenceDiff(
new OffsetRange(s1 + 1, lastAligningPosS1),
new OffsetRange(s2 + 1, lastAligningPosS2),
));
}
lastAligningPosS1 = s1;
lastAligningPosS2 = s2;
}
let s1 = sequence1.length - 1;
let s2 = sequence2.length - 1;
while (s1 >= 0 && s2 >= 0) {
if (directions.get(s1, s2) === 3) {
reportDecreasingAligningPositions(s1, s2);
s1--;
s2--;
} else {
if (directions.get(s1, s2) === 1) {
s1--;
} else {
s2--;
}
}
}
reportDecreasingAligningPositions(-1, -1);
result.reverse();
return result;
}
}

View file

@ -0,0 +1,150 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IDiffAlgorithm, ISequence, SequenceDiff, OffsetRange } from 'vs/editor/common/diff/algorithms/diffAlgorithm';
/**
* An O(ND) diff algorithm that has a quadratic space worst-case complexity.
*/
export class MyersDiffAlgorithm implements IDiffAlgorithm {
compute(seq1: ISequence, seq2: ISequence): SequenceDiff[] {
function getXAfterSnake(x: number, y: number): number {
while (x < seq1.length && y < seq2.length && seq1.getElement(x) === seq2.getElement(y)) {
x++;
y++;
}
return x;
}
let d = 0;
// V[k]: X value of longest d-line that ends in diagonal k.
// d-line: path from (0,0) to (x,y) that uses exactly d non-diagonals.
// diagonal k: Set of points (x,y) with x-y = k.
const V = new FastInt32Array();
V.set(0, getXAfterSnake(0, 0));
const paths = new FastArrayNegativeIndices<SnakePath | null>();
paths.set(0, V.get(0) === 0 ? null : new SnakePath(null, 0, 0, V.get(0)));
let k = 0;
loop: while (true) {
d++;
for (k = -d; k <= d; k += 2) {
const maxXofDLineTop = k === d ? -1 : V.get(k + 1); // We take a vertical non-diagonal
const maxXofDLineLeft = k === -d ? -1 : V.get(k - 1) + 1; // We take a horizontal non-diagonal (+1 x)
const x = Math.min(Math.max(maxXofDLineTop, maxXofDLineLeft), seq1.length);
const y = x - k;
const newMaxX = getXAfterSnake(x, y);
V.set(k, newMaxX);
const lastPath = x === maxXofDLineTop ? paths.get(k + 1) : paths.get(k - 1);
paths.set(k, newMaxX !== x ? new SnakePath(lastPath, x, y, newMaxX - x) : lastPath);
if (V.get(k) === seq1.length && V.get(k) - k === seq2.length) {
break loop;
}
}
}
let path = paths.get(k);
const result: SequenceDiff[] = [];
let lastAligningPosS1: number = seq1.length;
let lastAligningPosS2: number = seq2.length;
while (true) {
const endX = path ? path.x + path.length : 0;
const endY = path ? path.y + path.length : 0;
if (endX !== lastAligningPosS1 || endY !== lastAligningPosS2) {
result.push(new SequenceDiff(
new OffsetRange(endX, lastAligningPosS1),
new OffsetRange(endY, lastAligningPosS2),
));
}
if (!path) {
break;
}
lastAligningPosS1 = path.x;
lastAligningPosS2 = path.y;
path = path.prev;
}
result.reverse();
return result;
}
}
class SnakePath {
constructor(
public readonly prev: SnakePath | null,
public readonly x: number,
public readonly y: number,
public readonly length: number
) {
}
}
/**
* An array that supports fast negative indices.
*/
class FastInt32Array {
private positiveArr: Int32Array = new Int32Array(10);
private negativeArr: Int32Array = new Int32Array(10);
get(idx: number): number {
if (idx < 0) {
idx = -idx - 1;
return this.negativeArr[idx];
} else {
return this.positiveArr[idx];
}
}
set(idx: number, value: number): void {
if (idx < 0) {
idx = -idx - 1;
if (idx > this.negativeArr.length) {
const arr = this.negativeArr;
this.negativeArr = new Int32Array(arr.length * 2);
this.negativeArr.set(arr);
}
this.negativeArr[idx] = value;
} else {
if (idx > this.positiveArr.length) {
const arr = this.positiveArr;
this.positiveArr = new Int32Array(arr.length * 2);
this.positiveArr.set(arr);
}
this.positiveArr[idx] = value;
}
}
}
/**
* An array that supports fast negative indices.
*/
class FastArrayNegativeIndices<T> {
private readonly positiveArr: T[] = [];
private readonly negativeArr: T[] = [];
get(idx: number): T {
if (idx < 0) {
idx = -idx - 1;
return this.negativeArr[idx];
} else {
return this.positiveArr[idx];
}
}
set(idx: number, value: T): void {
if (idx < 0) {
idx = -idx - 1;
this.negativeArr[idx] = value;
} else {
this.positiveArr[idx] = value;
}
}
}

View file

@ -0,0 +1,20 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export class Array2D<T> {
private readonly array: T[] = [];
constructor(public readonly width: number, public readonly height: number) {
this.array = new Array<T>(width * height);
}
get(x: number, y: number): T {
return this.array[x + y * this.width];
}
set(x: number, y: number, value: T): void {
this.array[x + y * this.width] = value;
}
}

View file

@ -0,0 +1,23 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { LineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer';
import { ITextModel } from 'vs/editor/common/model';
export interface IDocumentDiffProvider {
computeDiff(original: ITextModel, modified: ITextModel, options: IDocumentDiffProviderOptions): Promise<IDocumentDiff>;
}
export interface IDocumentDiffProviderOptions {
ignoreTrimWhitespace: boolean;
maxComputationTime: number;
diffAlgorithm: 'smart' | 'experimental';
}
export interface IDocumentDiff {
readonly identical: boolean;
readonly quitEarly: boolean;
readonly changes: LineRangeMapping[];
}

View file

@ -0,0 +1,69 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Range } from 'vs/editor/common/core/range';
export interface ILinesDiffComputer {
computeDiff(originalLines: string[], modifiedLines: string[], options: ILinesDiffComputerOptions): ILinesDiff;
}
export interface ILinesDiffComputerOptions {
ignoreTrimWhitespace: boolean;
maxComputationTime: number;
}
export interface ILinesDiff {
readonly quitEarly: boolean;
readonly changes: LineRangeMapping[];
}
export class LineRangeMapping {
constructor(
readonly originalRange: LineRange,
readonly modifiedRange: LineRange,
/**
* Meaning of `undefined` unclear.
*/
readonly innerChanges: RangeMapping[] | undefined,
) { }
toString(): string {
return `{${this.originalRange.toString()}->${this.modifiedRange.toString()}}`;
}
}
export class RangeMapping {
constructor(
readonly originalRange: Range,
readonly modifiedRange: Range,
) { }
toString(): string {
return `{${this.originalRange.toString()}->${this.modifiedRange.toString()}}`;
}
}
/**
* 1-based.
*/
export class LineRange {
constructor(public readonly startLineNumber: number, public readonly endLineNumberExclusive: number) { }
get isEmpty(): boolean {
return this.startLineNumber === this.endLineNumberExclusive;
}
public delta(offset: number): LineRange {
return new LineRange(this.startLineNumber + offset, this.endLineNumberExclusive + offset);
}
public get length(): number {
return this.endLineNumberExclusive - this.startLineNumber;
}
toString(): string {
return `[${this.startLineNumber},${this.endLineNumberExclusive})`;
}
}

View file

@ -0,0 +1,13 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { MyersDiffAlgorithm } from 'vs/editor/common/diff/algorithms/myersDiffAlgorithm';
import { SmartLinesDiffComputer } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { StandardLinesDiffComputer } from 'vs/editor/common/diff/standardLinesDiffComputer';
export const linesDiffComputers = {
smart: new SmartLinesDiffComputer(),
experimental: new StandardLinesDiffComputer(new MyersDiffAlgorithm())
};

View file

@ -5,10 +5,54 @@
import { CharCode } from 'vs/base/common/charCode';
import { IDiffChange, ISequence, LcsDiff, IDiffResult } from 'vs/base/common/diff/diff';
import { ILinesDiffComputer, ILinesDiff, ILinesDiffComputerOptions, LineRange, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer';
import * as strings from 'vs/base/common/strings';
import { Range } from 'vs/editor/common/core/range';
const MINIMUM_MATCHING_CHARACTER_LENGTH = 3;
export class SmartLinesDiffComputer implements ILinesDiffComputer {
computeDiff(originalLines: string[], modifiedLines: string[], options: ILinesDiffComputerOptions): ILinesDiff {
const diffComputer = new DiffComputer(originalLines, modifiedLines, {
maxComputationTime: options.maxComputationTime,
shouldIgnoreTrimWhitespace: options.ignoreTrimWhitespace,
shouldComputeCharChanges: true,
shouldMakePrettyDiff: true,
shouldPostProcessCharChanges: true,
});
const result = diffComputer.computeDiff();
return {
quitEarly: result.quitEarly,
changes: result.changes.map(c => {
let originalRange: LineRange;
if (c.originalEndLineNumber === 0) {
// Insertion
originalRange = new LineRange(c.originalStartLineNumber + 1, c.originalStartLineNumber + 1);
} else {
originalRange = new LineRange(c.originalStartLineNumber, c.originalEndLineNumber + 1);
}
let modifiedRange: LineRange;
if (c.modifiedEndLineNumber === 0) {
// Deletion
modifiedRange = new LineRange(c.modifiedStartLineNumber + 1, c.modifiedStartLineNumber + 1);
} else {
modifiedRange = new LineRange(c.modifiedStartLineNumber, c.modifiedEndLineNumber + 1);
}
return {
originalRange,
modifiedRange,
innerChanges: c.charChanges?.map(c => new RangeMapping(
new Range(c.originalStartLineNumber, c.originalStartColumn, c.originalEndLineNumber, c.originalEndColumn),
new Range(c.modifiedStartLineNumber, c.modifiedStartColumn, c.modifiedEndLineNumber, c.modifiedEndColumn),
))
};
})
};
}
}
export interface IDiffComputationResult {
quitEarly: boolean;
identical: boolean;
@ -395,16 +439,7 @@ export class DiffComputer {
originalEndLineNumber: 1,
modifiedStartLineNumber: 1,
modifiedEndLineNumber: this.modified.lines.length,
charChanges: [{
modifiedEndColumn: 0,
modifiedEndLineNumber: 0,
modifiedStartColumn: 0,
modifiedStartLineNumber: 0,
originalEndColumn: 0,
originalEndLineNumber: 0,
originalStartColumn: 0,
originalStartLineNumber: 0
}]
charChanges: undefined
}]
};
}
@ -418,16 +453,7 @@ export class DiffComputer {
originalEndLineNumber: this.original.lines.length,
modifiedStartLineNumber: 1,
modifiedEndLineNumber: 1,
charChanges: [{
modifiedEndColumn: 0,
modifiedEndLineNumber: 0,
modifiedStartColumn: 0,
modifiedStartLineNumber: 0,
originalEndColumn: 0,
originalEndLineNumber: 0,
originalStartColumn: 0,
originalStartLineNumber: 0
}]
charChanges: undefined
}]
};
}

View file

@ -0,0 +1,196 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { IDiffAlgorithm, SequenceFromIntArray, OffsetRange, SequenceDiff, ISequence } from 'vs/editor/common/diff/algorithms/diffAlgorithm';
import { DynamicProgrammingDiffing } from 'vs/editor/common/diff/algorithms/dynamicProgrammingDiffing';
import { ILinesDiff, ILinesDiffComputer, ILinesDiffComputerOptions, LineRange, LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer';
export class StandardLinesDiffComputer implements ILinesDiffComputer {
private readonly lineDiffingAlgorithm = new DynamicProgrammingDiffing();
constructor(
private readonly detailedDiffingAlgorithm: IDiffAlgorithm
) { }
computeDiff(originalLines: string[], modifiedLines: string[], options: ILinesDiffComputerOptions): ILinesDiff {
const perfectHashes = new Map<string, number>();
function getOrCreateHash(text: string): number {
let hash = perfectHashes.get(text);
if (hash === undefined) {
hash = perfectHashes.size;
perfectHashes.set(text, hash);
}
return hash;
}
const srcDocLines = originalLines.map((l) => getOrCreateHash(l.trim()));
const tgtDocLines = modifiedLines.map((l) => getOrCreateHash(l.trim()));
const lineAlignments = this.lineDiffingAlgorithm.compute(
new SequenceFromIntArray(srcDocLines),
new SequenceFromIntArray(tgtDocLines),
(offset1, offset2) =>
originalLines[offset1] === modifiedLines[offset2]
? modifiedLines[offset2].length === 0
? 0.1
: 1// + Math.log(1 + modifiedLines[offset2].length)
: 0.99
);
const alignments: RangeMapping[] = [];
for (let diff of lineAlignments) {
// Move line diffs up to improve the case of
// AxBAzB -> AxBA(yBA)zB to
// AxBAzB -> AxB(AyB)AzB
if (
(diff.seq1Range.start > 0 &&
diff.seq1Range.length > 0 &&
srcDocLines[diff.seq1Range.start - 1] ===
srcDocLines[diff.seq1Range.endExclusive - 1]) ||
(diff.seq2Range.start > 0 &&
diff.seq2Range.length > 0 &&
tgtDocLines[diff.seq2Range.start - 1] ===
tgtDocLines[diff.seq2Range.endExclusive - 1])
) {
diff = new SequenceDiff(
diff.seq1Range.delta(-1),
diff.seq2Range.delta(-1),
);
}
for (const a of this.refineDiff(originalLines, modifiedLines, diff)) {
alignments.push(a);
}
}
const changes: LineRangeMapping[] = lineRangeMappingFromRangeMappings(alignments);
return {
quitEarly: false,
changes: changes,
};
}
private refineDiff(originalLines: string[], modifiedLines: string[], diff: SequenceDiff): RangeMapping[] {
const sourceSlice = new Slice(originalLines, diff.seq1Range);
const targetSlice = new Slice(modifiedLines, diff.seq2Range);
const diffs = this.detailedDiffingAlgorithm.compute(sourceSlice, targetSlice);
return diffs.map(
(d) =>
new RangeMapping(
sourceSlice.translateRange(d.seq1Range).delta(diff.seq1Range.start),
targetSlice.translateRange(d.seq2Range).delta(diff.seq2Range.start)
)
);
}
}
export function lineRangeMappingFromRangeMappings(alignments: RangeMapping[]): LineRangeMapping[] {
const changes: LineRangeMapping[] = [];
for (const g of group(
alignments,
(a1, a2) => a2.modifiedRange.startLineNumber - (a1.modifiedRange.endLineNumber - (a1.modifiedRange.endColumn > 1 ? 0 : 1)) <= 1
)) {
const first = g[0];
const last = g[g.length - 1];
changes.push(new LineRangeMapping(
new LineRange(
first.originalRange.startLineNumber,
last.originalRange.endLineNumber + (last.originalRange.endColumn > 1 || last.modifiedRange.endColumn > 1 ? 1 : 0)
),
new LineRange(
first.modifiedRange.startLineNumber,
last.modifiedRange.endLineNumber + (last.originalRange.endColumn > 1 || last.modifiedRange.endColumn > 1 ? 1 : 0)
),
g
));
}
return changes;
}
function* group<T>(items: Iterable<T>, shouldBeGrouped: (item1: T, item2: T) => boolean): Iterable<T[]> {
let currentGroup: T[] | undefined;
let last: T | undefined;
for (const item of items) {
if (last !== undefined && shouldBeGrouped(last, item)) {
currentGroup!.push(item);
} else {
if (currentGroup) {
yield currentGroup;
}
currentGroup = [item];
}
last = item;
}
if (currentGroup) {
yield currentGroup;
}
}
class Slice implements ISequence {
private readonly elements: Int32Array;
private readonly firstCharOnLineOffsets: Int32Array;
constructor(public readonly lines: string[], public readonly lineRange: OffsetRange) {
let chars = 0;
this.firstCharOnLineOffsets = new Int32Array(lineRange.length);
for (let i = lineRange.start; i < lineRange.endExclusive; i++) {
const line = lines[i];
chars += line.length;
this.firstCharOnLineOffsets[i - lineRange.start] = chars + 1;
chars++;
}
this.elements = new Int32Array(chars);
let offset = 0;
for (let i = lineRange.start; i < lineRange.endExclusive; i++) {
const line = lines[i];
for (let i = 0; i < line.length; i++) {
this.elements[offset + i] = line.charCodeAt(i);
}
offset += line.length;
if (i < lines.length - 1) {
this.elements[offset] = '\n'.charCodeAt(0);
offset += 1;
}
}
}
getElement(offset: number): number {
return this.elements[offset];
}
get length(): number {
return this.elements.length;
}
public translateOffset(offset: number): Position {
// find smallest i, so that lineBreakOffsets[i] > offset using binary search
let i = 0;
let j = this.firstCharOnLineOffsets.length;
while (i < j) {
const k = Math.floor((i + j) / 2);
if (this.firstCharOnLineOffsets[k] > offset) {
j = k;
} else {
i = k + 1;
}
}
const offsetOfPrevLineBreak = i === 0 ? 0 : this.firstCharOnLineOffsets[i - 1];
return new Position(i + 1, offset - offsetOfPrevLineBreak + 1);
}
public translateRange(range: OffsetRange): Range {
return Range.fromPositions(this.translateOffset(range.start), this.translateOffset(range.endExclusive));
}
}

View file

@ -10,19 +10,22 @@ import { URI } from 'vs/base/common/uri';
import { IRequestHandler } from 'vs/base/common/worker/simpleWorker';
import { IPosition, Position } from 'vs/editor/common/core/position';
import { IRange, Range } from 'vs/editor/common/core/range';
import { DiffComputer, IChange, IDiffComputationResult } from 'vs/editor/common/diff/diffComputer';
import { EndOfLineSequence, ITextModel } from 'vs/editor/common/model';
import { IMirrorTextModel, IModelChangedEvent, MirrorTextModel as BaseMirrorModel } from 'vs/editor/common/model/mirrorTextModel';
import { ensureValidWordDefinition, getWordAtText, IWordAtPosition } from 'vs/editor/common/core/wordHelper';
import { IInplaceReplaceSupportResult, ILink, TextEdit } from 'vs/editor/common/languages';
import { ILinkComputerTarget, computeLinks } from 'vs/editor/common/languages/linkComputer';
import { BasicInplaceReplace } from 'vs/editor/common/languages/supports/inplaceReplaceSupport';
import { IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker';
import { IDiffComputationResult, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker';
import { createMonacoBaseAPI } from 'vs/editor/common/services/editorBaseApi';
import { IEditorWorkerHost } from 'vs/editor/common/services/editorWorkerHost';
import { StopWatch } from 'vs/base/common/stopwatch';
import { UnicodeTextModelHighlighter, UnicodeHighlighterOptions } from 'vs/editor/common/services/unicodeTextModelHighlighter';
import { DiffComputer, IChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { ILinesDiffComputer } from 'vs/editor/common/diff/linesDiffComputer';
import { linesDiffComputers } from 'vs/editor/common/diff/linesDiffComputers';
import { createProxyObject, getAllMethodNames } from 'vs/base/common/objects';
import { IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider';
export interface IMirrorModel extends IMirrorTextModel {
readonly uri: URI;
@ -381,33 +384,39 @@ export class EditorSimpleWorker implements IRequestHandler, IDisposable {
// ---- BEGIN diff --------------------------------------------------------------------------
public async computeDiff(originalUrl: string, modifiedUrl: string, ignoreTrimWhitespace: boolean, maxComputationTime: number): Promise<IDiffComputationResult | null> {
public async computeDiff(originalUrl: string, modifiedUrl: string, options: IDocumentDiffProviderOptions): Promise<IDiffComputationResult | null> {
const original = this._getModel(originalUrl);
const modified = this._getModel(modifiedUrl);
if (!original || !modified) {
return null;
}
return EditorSimpleWorker.computeDiff(original, modified, ignoreTrimWhitespace, maxComputationTime);
return EditorSimpleWorker.computeDiff(original, modified, options);
}
public static computeDiff(originalTextModel: ICommonModel | ITextModel, modifiedTextModel: ICommonModel | ITextModel, ignoreTrimWhitespace: boolean, maxComputationTime: number): IDiffComputationResult | null {
private static computeDiff(originalTextModel: ICommonModel | ITextModel, modifiedTextModel: ICommonModel | ITextModel, options: IDocumentDiffProviderOptions): IDiffComputationResult {
const diffAlgorithm: ILinesDiffComputer = options.diffAlgorithm === 'experimental' ? linesDiffComputers.experimental : linesDiffComputers.smart;
const originalLines = originalTextModel.getLinesContent();
const modifiedLines = modifiedTextModel.getLinesContent();
const diffComputer = new DiffComputer(originalLines, modifiedLines, {
shouldComputeCharChanges: true,
shouldPostProcessCharChanges: true,
shouldIgnoreTrimWhitespace: ignoreTrimWhitespace,
shouldMakePrettyDiff: true,
maxComputationTime: maxComputationTime
});
const diffResult = diffComputer.computeDiff();
const identical = (diffResult.changes.length > 0 ? false : this._modelsAreIdentical(originalTextModel, modifiedTextModel));
const result = diffAlgorithm.computeDiff(originalLines, modifiedLines, options);
const identical = (result.changes.length > 0 ? false : this._modelsAreIdentical(originalTextModel, modifiedTextModel));
return {
quitEarly: diffResult.quitEarly,
identical: identical,
changes: diffResult.changes
identical,
quitEarly: result.quitEarly,
changes: result.changes.map(m => ([m.originalRange.startLineNumber, m.originalRange.endLineNumberExclusive, m.modifiedRange.startLineNumber, m.modifiedRange.endLineNumberExclusive, m.innerChanges?.map(m => [
m.originalRange.startLineNumber,
m.originalRange.startColumn,
m.originalRange.endLineNumber,
m.originalRange.endColumn,
m.modifiedRange.startLineNumber,
m.modifiedRange.startColumn,
m.modifiedRange.endLineNumber,
m.modifiedRange.endColumn,
])]))
};
}

View file

@ -5,7 +5,8 @@
import { URI } from 'vs/base/common/uri';
import { IRange } from 'vs/editor/common/core/range';
import { IChange, IDiffComputationResult } from 'vs/editor/common/diff/diffComputer';
import { IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider';
import { IChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { IInplaceReplaceSupportResult, TextEdit } from 'vs/editor/common/languages';
import { UnicodeHighlighterOptions } from 'vs/editor/common/services/unicodeTextModelHighlighter';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
@ -19,7 +20,7 @@ export interface IEditorWorkerService {
canComputeUnicodeHighlights(uri: URI): boolean;
computedUnicodeHighlights(uri: URI, options: UnicodeHighlighterOptions, range?: IRange): Promise<IUnicodeHighlightsResult>;
computeDiff(original: URI, modified: URI, ignoreTrimWhitespace: boolean, maxComputationTime: number): Promise<IDiffComputationResult | null>;
computeDiff(original: URI, modified: URI, options: IDocumentDiffProviderOptions): Promise<IDiffComputationResult | null>;
canComputeDirtyDiff(original: URI, modified: URI): boolean;
computeDirtyDiff(original: URI, modified: URI, ignoreTrimWhitespace: boolean): Promise<IChange[] | null>;
@ -33,6 +34,32 @@ export interface IEditorWorkerService {
navigateValueSet(resource: URI, range: IRange, up: boolean): Promise<IInplaceReplaceSupportResult | null>;
}
export interface IDiffComputationResult {
quitEarly: boolean;
changes: ILineChange[];
identical: boolean;
}
export type ILineChange = [
originalStartLine: number,
originalEndLine: number,
modifiedStartLine: number,
modifiedEndLine: number,
charChanges: ICharChange[] | undefined,
];
export type ICharChange = [
originalStartLine: number,
originalStartColumn: number,
originalEndLine: number,
originalEndColumn: number,
modifiedStartLine: number,
modifiedStartColumn: number,
modifiedEndLine: number,
modifiedEndColumn: number,
];
export interface IUnicodeHighlightsResult {
ranges: IRange[];
hasMore: boolean;

View file

@ -5,7 +5,7 @@
import * as assert from 'assert';
import { Constants } from 'vs/base/common/uint';
import { Range } from 'vs/editor/common/core/range';
import { DiffComputer, ICharChange, ILineChange } from 'vs/editor/common/diff/diffComputer';
import { DiffComputer, ICharChange, ILineChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { IIdentifiedSingleEditOperation, ITextModel } from 'vs/editor/common/model';
import { createTextModel } from 'vs/editor/test/common/testTextModel';
@ -527,9 +527,7 @@ suite('Editor Diff - DiffComputer', () => {
const original = [''];
const modified = ['something'];
const expected = [
createLineChange(1, 1, 1, 1, [
createCharChange(0, 0, 0, 0, 0, 0, 0, 0)
])
createLineChange(1, 1, 1, 1, undefined)
];
assertDiff(original, modified, expected, true, false, true);
});
@ -538,9 +536,7 @@ suite('Editor Diff - DiffComputer', () => {
const original = [''];
const modified = ['something', 'something else'];
const expected = [
createLineChange(1, 1, 1, 2, [
createCharChange(0, 0, 0, 0, 0, 0, 0, 0)
])
createLineChange(1, 1, 1, 2, undefined)
];
assertDiff(original, modified, expected, true, false, true);
});
@ -549,9 +545,7 @@ suite('Editor Diff - DiffComputer', () => {
const original = ['something', 'something else'];
const modified = [''];
const expected = [
createLineChange(1, 2, 1, 1, [
createCharChange(0, 0, 0, 0, 0, 0, 0, 0)
])
createLineChange(1, 2, 1, 1, undefined)
];
assertDiff(original, modified, expected, true, false, true);
});
@ -560,9 +554,7 @@ suite('Editor Diff - DiffComputer', () => {
const original = ['something'];
const modified = [''];
const expected = [
createLineChange(1, 1, 1, 1, [
createCharChange(0, 0, 0, 0, 0, 0, 0, 0)
])
createLineChange(1, 1, 1, 1, undefined)
];
assertDiff(original, modified, expected, true, false, true);
});

View file

@ -0,0 +1,63 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert = require('assert');
import { Range } from 'vs/editor/common/core/range';
import { LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer';
import { lineRangeMappingFromRangeMappings } from 'vs/editor/common/diff/standardLinesDiffComputer';
suite('standardLinesDiffCompute', () => {
test('1', () => {
assert.deepStrictEqual(
toJson(
lineRangeMappingFromRangeMappings([
new RangeMapping(r([1, 1, 1, 1]), r([1, 1, 1, 2])),
])
),
(["{[1,2)->[1,2)}"])
);
});
test('2', () => {
assert.deepStrictEqual(
toJson(
lineRangeMappingFromRangeMappings([
new RangeMapping(r([1, 1, 1, 2]), r([1, 1, 1, 1])),
])
),
(["{[1,2)->[1,2)}"])
);
});
test('3', () => {
assert.deepStrictEqual(
toJson(
lineRangeMappingFromRangeMappings([
new RangeMapping(r([1, 1, 2, 1]), r([1, 1, 1, 1])),
])
),
(["{[1,2)->[1,1)}"])
);
});
test('4', () => {
assert.deepStrictEqual(
toJson(
lineRangeMappingFromRangeMappings([
new RangeMapping(r([1, 1, 1, 1]), r([1, 1, 2, 1])),
])
),
(["{[1,1)->[1,2)}"])
);
});
});
function r(values: [startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number]): Range {
return new Range(values[0], values[1], values[2], values[3]);
}
function toJson(mappings: LineRangeMapping[]): unknown {
return mappings.map(m => m.toString());
}

View file

@ -5,9 +5,10 @@
import { URI } from 'vs/base/common/uri';
import { IRange } from 'vs/editor/common/core/range';
import { IEditorWorkerService, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker';
import { IDiffComputationResult, IEditorWorkerService, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker';
import { TextEdit, IInplaceReplaceSupportResult } from 'vs/editor/common/languages';
import { IChange, IDiffComputationResult } from 'vs/editor/common/diff/diffComputer';
import { IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider';
import { IChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
export class TestEditorWorkerService implements IEditorWorkerService {
@ -15,7 +16,7 @@ export class TestEditorWorkerService implements IEditorWorkerService {
canComputeUnicodeHighlights(uri: URI): boolean { return false; }
async computedUnicodeHighlights(uri: URI): Promise<IUnicodeHighlightsResult> { return { ranges: [], hasMore: false, ambiguousCharacterCount: 0, invisibleCharacterCount: 0, nonBasicAsciiCharacterCount: 0 }; }
async computeDiff(original: URI, modified: URI, ignoreTrimWhitespace: boolean, maxComputationTime: number): Promise<IDiffComputationResult | null> { return null; }
async computeDiff(original: URI, modified: URI, options: IDocumentDiffProviderOptions): Promise<IDiffComputationResult | null> { return null; }
canComputeDirtyDiff(original: URI, modified: URI): boolean { return false; }
async computeDirtyDiff(original: URI, modified: URI, ignoreTrimWhitespace: boolean): Promise<IChange[] | null> { return null; }
async computeMoreMinimalEdits(resource: URI, edits: TextEdit[] | null | undefined): Promise<TextEdit[] | undefined> { return undefined; }

8
src/vs/monaco.d.ts vendored
View file

@ -700,6 +700,10 @@ declare namespace monaco {
* Create a new empty range using this range's start position.
*/
collapseToStart(): Range;
/**
* Moves the range by the given amount of lines.
*/
delta(lineCount: number): Range;
/**
* Create a new empty range using this range's start position.
*/
@ -3505,6 +3509,10 @@ declare namespace monaco.editor {
* Control the wrapping of the diff editor.
*/
diffWordWrap?: 'off' | 'on' | 'inherit';
/**
* Diff Algorithm
*/
diffAlgorithm?: 'smart' | 'experimental';
}
/**

View file

@ -23,7 +23,7 @@ import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editor
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
import { ILineChange } from 'vs/editor/common/diff/diffComputer';
import { ILineChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers';
import { IEditorControl } from 'vs/workbench/common/editor';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';

View file

@ -19,7 +19,7 @@ import { ISingleEditOperation } from 'vs/editor/common/core/editOperation';
import { IPosition } from 'vs/editor/common/core/position';
import { IRange } from 'vs/editor/common/core/range';
import { ISelection, Selection } from 'vs/editor/common/core/selection';
import { ILineChange } from 'vs/editor/common/diff/diffComputer';
import { ILineChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import * as editorCommon from 'vs/editor/common/editorCommon';
import * as languages from 'vs/editor/common/languages';
import { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes';

View file

@ -9,7 +9,7 @@ import { registerDiffEditorContribution } from 'vs/editor/browser/editorExtensio
import { IDiffEditorContribution } from 'vs/editor/common/editorCommon';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { FloatingClickWidget } from 'vs/workbench/browser/codeeditor';
import { IDiffComputationResult } from 'vs/editor/common/diff/diffComputer';
import { IDiffComputationResult } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';

View file

@ -5,6 +5,7 @@
import { localize } from 'vs/nls';
import { registerAction2 } from 'vs/platform/actions/common/actions';
import { Extensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry';
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
import { Registry } from 'vs/platform/registry/common/platform';
import { EditorPaneDescriptor, IEditorPaneRegistry } from 'vs/workbench/browser/editor';
@ -33,6 +34,20 @@ Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEdit
MergeEditorSerializer
);
Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration({
properties: {
'mergeEditor.diffAlgorithm': {
type: 'string',
enum: ['smart', 'experimental'],
default: 'smart',
markdownEnumDescriptions: [
localize('diffAlgorithm.smart', "Uses the default diffing algorithm."),
localize('diffAlgorithm.experimental', "Uses an experimental diffing algorithm."),
]
},
}
});
registerAction2(OpenResultResource);
registerAction2(SetMixedLayout);
registerAction2(SetColumnLayout);

View file

@ -16,11 +16,12 @@ import { ILabelService } from 'vs/platform/label/common/label';
import { DEFAULT_EDITOR_ASSOCIATION, EditorInputCapabilities, IEditorIdentifier, IResourceMergeEditorInput, isResourceMergeEditorInput, IUntypedEditorInput } from 'vs/workbench/common/editor';
import { EditorInput, IEditorCloseHandler } from 'vs/workbench/common/editor/editorInput';
import { AbstractTextResourceEditorInput } from 'vs/workbench/common/editor/textResourceEditorInput';
import { EditorWorkerServiceDiffComputer } from 'vs/workbench/contrib/mergeEditor/browser/model/diffComputer';
import { MergeDiffComputer } from 'vs/workbench/contrib/mergeEditor/browser/model/diffComputer';
import { MergeEditorModel } from 'vs/workbench/contrib/mergeEditor/browser/model/mergeEditorModel';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { ILanguageSupport, ITextFileEditorModel, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { autorun } from 'vs/base/common/observable';
import { WorkerBasedDocumentDiffProvider } from 'vs/editor/browser/widget/workerBasedDocumentDiffProvider';
export class MergeEditorInputData {
constructor(
@ -117,7 +118,7 @@ export class MergeEditorInput extends AbstractTextResourceEditorInput implements
this.input2.detail,
this.input2.description,
result.object.textEditorModel,
this._instaService.createInstance(EditorWorkerServiceDiffComputer),
this._instaService.createInstance(MergeDiffComputer, this._instaService.createInstance(WorkerBasedDocumentDiffProvider)),
{
resetUnknownOnInitialization: true
},

View file

@ -3,95 +3,71 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IReader, observableFromEvent } from 'vs/base/common/observable';
import { isDefined } from 'vs/base/common/types';
import { Range } from 'vs/editor/common/core/range';
import { ICharChange, IDiffComputationResult, ILineChange } from 'vs/editor/common/diff/diffComputer';
import { IDocumentDiffProvider } from 'vs/editor/common/diff/documentDiffProvider';
import { LineRange as DiffLineRange, RangeMapping as DiffRangeMapping } from 'vs/editor/common/diff/linesDiffComputer';
import { ITextModel } from 'vs/editor/common/model';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { LineRange } from 'vs/workbench/contrib/mergeEditor/browser/model/lineRange';
import { DetailedLineRangeMapping, RangeMapping } from 'vs/workbench/contrib/mergeEditor/browser/model/mapping';
export interface IDiffComputer {
computeDiff(textModel1: ITextModel, textModel2: ITextModel): Promise<IDiffComputerResult>;
export interface IMergeDiffComputer {
computeDiff(textModel1: ITextModel, textModel2: ITextModel, reader: IReader): Promise<IMergeDiffComputerResult>;
}
export interface IDiffComputerResult {
export interface IMergeDiffComputerResult {
diffs: DetailedLineRangeMapping[] | null;
}
export class EditorWorkerServiceDiffComputer implements IDiffComputer {
constructor(@IEditorWorkerService private readonly editorWorkerService: IEditorWorkerService) { }
export class MergeDiffComputer implements IMergeDiffComputer {
async computeDiff(textModel1: ITextModel, textModel2: ITextModel): Promise<IDiffComputerResult> {
const diffs = await this.editorWorkerService.computeDiff(textModel1.uri, textModel2.uri, false, 1000);
if (textModel1.isDisposed() || textModel2.isDisposed()) {
// In the meantime, models could be disposed -> early return
return { diffs: null };
}
if (!diffs) {
return { diffs: null };
}
return { diffs: EditorWorkerServiceDiffComputer.fromDiffComputationResult(diffs, textModel1, textModel2) };
}
public static fromDiffComputationResult(result: IDiffComputationResult, textModel1: ITextModel, textModel2: ITextModel): DetailedLineRangeMapping[] {
return result.changes.map((c) => fromLineChange(c, textModel1, textModel2));
}
}
function fromLineChange(lineChange: ILineChange, originalTextModel: ITextModel, modifiedTextModel: ITextModel): DetailedLineRangeMapping {
let originalRange: LineRange;
if (lineChange.originalEndLineNumber === 0) {
// Insertion
originalRange = new LineRange(lineChange.originalStartLineNumber + 1, 0);
} else {
originalRange = new LineRange(lineChange.originalStartLineNumber, lineChange.originalEndLineNumber - lineChange.originalStartLineNumber + 1);
}
let modifiedRange: LineRange;
if (lineChange.modifiedEndLineNumber === 0) {
// Deletion
modifiedRange = new LineRange(lineChange.modifiedStartLineNumber + 1, 0);
} else {
modifiedRange = new LineRange(lineChange.modifiedStartLineNumber, lineChange.modifiedEndLineNumber - lineChange.modifiedStartLineNumber + 1);
}
let innerDiffs = lineChange.charChanges?.map(c => rangeMappingFromCharChange(c, originalTextModel, modifiedTextModel)).filter(isDefined);
if (!innerDiffs || innerDiffs.length === 0) {
innerDiffs = [rangeMappingFromLineRanges(originalRange, modifiedRange)];
}
return new DetailedLineRangeMapping(
originalRange,
originalTextModel,
modifiedRange,
modifiedTextModel,
innerDiffs
private readonly mergeAlgorithm = observableFromEvent(
this.configurationService.onDidChangeConfiguration,
() => /** @description config: mergeAlgorithm.diffAlgorithm */ this.configurationService.getValue<'smart' | 'experimental'>('mergeEditor.diffAlgorithm')
);
constructor(
private readonly documentDiffProvider: IDocumentDiffProvider,
@IConfigurationService private readonly configurationService: IConfigurationService,
) {
}
async computeDiff(textModel1: ITextModel, textModel2: ITextModel, reader: IReader): Promise<IMergeDiffComputerResult> {
const diffAlgorithm = this.mergeAlgorithm.read(reader);
const result = await this.documentDiffProvider.computeDiff(
textModel1,
textModel2,
{
ignoreTrimWhitespace: false,
maxComputationTime: 0,
diffAlgorithm,
}
);
const changes = result.changes.map(c =>
new DetailedLineRangeMapping(
toLineRange(c.originalRange),
textModel1,
toLineRange(c.modifiedRange),
textModel2,
c.innerChanges?.map(ic => normalizeRangeMapping(toRangeMapping(ic), textModel1, textModel2)).filter(isDefined)
)
);
return {
diffs: changes
};
}
}
function rangeMappingFromLineRanges(originalRange: LineRange, modifiedRange: LineRange): RangeMapping {
return new RangeMapping(
new Range(
originalRange.startLineNumber,
1,
originalRange.endLineNumberExclusive,
1,
),
new Range(
modifiedRange.startLineNumber,
1,
modifiedRange.endLineNumberExclusive,
1,
)
);
function toLineRange(range: DiffLineRange): LineRange {
return new LineRange(range.startLineNumber, range.length);
}
function rangeMappingFromCharChange(charChange: ICharChange, inputTextModel: ITextModel, modifiedTextModel: ITextModel): RangeMapping | undefined {
return normalizeRangeMapping(new RangeMapping(
new Range(charChange.originalStartLineNumber, charChange.originalStartColumn, charChange.originalEndLineNumber, charChange.originalEndColumn),
new Range(charChange.modifiedStartLineNumber, charChange.modifiedStartColumn, charChange.modifiedEndLineNumber, charChange.modifiedEndColumn)
), inputTextModel, modifiedTextModel);
function toRangeMapping(mapping: DiffRangeMapping): RangeMapping {
return new RangeMapping(mapping.originalRange, mapping.modifiedRange);
}
function normalizeRangeMapping(rangeMapping: RangeMapping, inputTextModel: ITextModel, outputTextModel: ITextModel): RangeMapping | undefined {
@ -102,6 +78,11 @@ function normalizeRangeMapping(rangeMapping: RangeMapping, inputTextModel: IText
return undefined;
}
if (rangeMapping.inputRange.startLineNumber > inputTextModel.getLineCount()
|| rangeMapping.outputRange.startLineNumber > outputTextModel.getLineCount()) {
return rangeMapping;
}
const originalStartsAtEndOfLine = isAtEndOfLine(rangeMapping.inputRange.startLineNumber, rangeMapping.inputRange.startColumn, inputTextModel);
const modifiedStartsAtEndOfLine = isAtEndOfLine(rangeMapping.outputRange.startLineNumber, rangeMapping.outputRange.startColumn, outputTextModel);

View file

@ -10,7 +10,7 @@ import { ILanguageService } from 'vs/editor/common/languages/language';
import { ITextModel, ITextSnapshot } from 'vs/editor/common/model';
import { IModelService } from 'vs/editor/common/services/model';
import { EditorModel } from 'vs/workbench/common/editor/editorModel';
import { IDiffComputer } from 'vs/workbench/contrib/mergeEditor/browser/model/diffComputer';
import { IMergeDiffComputer } from 'vs/workbench/contrib/mergeEditor/browser/model/diffComputer';
import { LineRange } from 'vs/workbench/contrib/mergeEditor/browser/model/lineRange';
import { DetailedLineRangeMapping, DocumentMapping, LineRangeMapping } from 'vs/workbench/contrib/mergeEditor/browser/model/mapping';
import { TextModelDiffChangeReason, TextModelDiffs, TextModelDiffState } from 'vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs';
@ -133,7 +133,7 @@ export class MergeEditorModel extends EditorModel {
readonly input2Detail: string | undefined,
readonly input2Description: string | undefined,
readonly result: ITextModel,
private readonly diffComputer: IDiffComputer,
private readonly diffComputer: IMergeDiffComputer,
options: { resetUnknownOnInitialization: boolean },
@IModelService private readonly modelService: IModelService,
@ILanguageService private readonly languageService: ILanguageService,

View file

@ -11,8 +11,8 @@ import { DetailedLineRangeMapping } from 'vs/workbench/contrib/mergeEditor/brows
import { LineRangeEdit } from 'vs/workbench/contrib/mergeEditor/browser/model/editing';
import { LineRange } from 'vs/workbench/contrib/mergeEditor/browser/model/lineRange';
import { ReentrancyBarrier } from 'vs/workbench/contrib/mergeEditor/browser/utils';
import { IDiffComputer } from './diffComputer';
import { IObservable, IReader, ITransaction, observableValue, transaction } from 'vs/base/common/observable';
import { IMergeDiffComputer } from './diffComputer';
import { autorun, IObservable, IReader, ITransaction, observableValue, transaction } from 'vs/base/common/observable';
export class TextModelDiffs extends Disposable {
private updateCount = 0;
@ -25,13 +25,19 @@ export class TextModelDiffs extends Disposable {
constructor(
private readonly baseTextModel: ITextModel,
private readonly textModel: ITextModel,
private readonly diffComputer: IDiffComputer,
private readonly diffComputer: IMergeDiffComputer,
) {
super();
this.update(true);
this._register(baseTextModel.onDidChangeContent(this.barrier.makeExclusive(() => this.update())));
this._register(textModel.onDidChangeContent(this.barrier.makeExclusive(() => this.update())));
const counter = observableValue('invalidation counter', 0);
this._register(autorun('Update diff state', reader => {
counter.read(reader);
this.update(reader);
}));
this._register(baseTextModel.onDidChangeContent(this.barrier.makeExclusive(() => { counter.set(counter.get() + 1, undefined); })));
this._register(textModel.onDidChangeContent(this.barrier.makeExclusive(() => { counter.set(counter.get() + 1, undefined); })));
this._register(toDisposable(() => {
this.isDisposed = true;
}));
@ -45,41 +51,47 @@ export class TextModelDiffs extends Disposable {
return this._diffs;
}
private async update(initializing: boolean = false): Promise<void> {
private isInitializing = true;
private update(reader: IReader): void {
this.updateCount++;
const currentUpdateCount = this.updateCount;
if (this._state.get() === TextModelDiffState.initializing) {
initializing = true;
this.isInitializing = true;
}
transaction(tx => {
/** @description Starting Diff Computation. */
this._state.set(
initializing ? TextModelDiffState.initializing : TextModelDiffState.updating,
this.isInitializing ? TextModelDiffState.initializing : TextModelDiffState.updating,
tx,
TextModelDiffChangeReason.other
);
});
const result = await this.diffComputer.computeDiff(this.baseTextModel, this.textModel);
if (this.isDisposed) {
return;
}
const result = this.diffComputer.computeDiff(this.baseTextModel, this.textModel, reader);
if (currentUpdateCount !== this.updateCount) {
// There is a newer update call
return;
}
transaction(tx => {
/** @description Completed Diff Computation */
if (result.diffs) {
this._state.set(TextModelDiffState.upToDate, tx, TextModelDiffChangeReason.textChange);
this._diffs.set(result.diffs, tx, TextModelDiffChangeReason.textChange);
} else {
this._state.set(TextModelDiffState.error, tx, TextModelDiffChangeReason.textChange);
result.then((result) => {
if (this.isDisposed) {
return;
}
if (currentUpdateCount !== this.updateCount) {
// There is a newer update call
return;
}
transaction(tx => {
/** @description Completed Diff Computation */
if (result.diffs) {
this._state.set(TextModelDiffState.upToDate, tx, TextModelDiffChangeReason.textChange);
this._diffs.set(result.diffs, tx, TextModelDiffChangeReason.textChange);
} else {
this._state.set(TextModelDiffState.error, tx, TextModelDiffChangeReason.textChange);
}
this.isInitializing = false;
});
});
}

View file

@ -8,11 +8,11 @@ import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { transaction } from 'vs/base/common/observable';
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
import { Range } from 'vs/editor/common/core/range';
import { linesDiffComputers } from 'vs/editor/common/diff/linesDiffComputers';
import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model';
import { EditorSimpleWorker } from 'vs/editor/common/services/editorSimpleWorker';
import { createModelServices, createTextModel } from 'vs/editor/test/common/testTextModel';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { EditorWorkerServiceDiffComputer } from 'vs/workbench/contrib/mergeEditor/browser/model/diffComputer';
import { MergeDiffComputer } from 'vs/workbench/contrib/mergeEditor/browser/model/diffComputer';
import { MergeEditorModel } from 'vs/workbench/contrib/mergeEditor/browser/model/mergeEditorModel';
suite('merge editor model', () => {
@ -268,21 +268,22 @@ class MergeModelInterface extends Disposable {
'',
'',
resultTextModel,
{
async computeDiff(textModel1, textModel2) {
const result = EditorSimpleWorker.computeDiff(textModel1, textModel2, false, 10000);
if (!result) {
return { diffs: null };
}
return {
diffs: EditorWorkerServiceDiffComputer.fromDiffComputationResult(
result,
textModel1,
textModel2
),
};
},
}, {
instantiationService.createInstance(MergeDiffComputer,
{
// Don't go through the webworker to improve unit test performance & reduce dependencies
async computeDiff(textModel1, textModel2) {
const result = linesDiffComputers.smart.computeDiff(
textModel1.getLinesContent(),
textModel2.getLinesContent(),
{ ignoreTrimWhitespace: false, maxComputationTime: 10000 }
);
return {
changes: result.changes,
quitEarly: result.quitEarly,
identical: result.changes.length === 0
};
},
}), {
resetUnknownOnInitialization: false
}
));

View file

@ -94,7 +94,8 @@ export const fixedDiffEditorOptions: IDiffEditorConstructionOptions = {
renderIndicators: true,
readOnly: false,
isInEmbeddedEditor: true,
renderOverviewRuler: false
renderOverviewRuler: false,
diffAlgorithm: 'smart',
};
class PropertyHeader extends Disposable {

View file

@ -15,7 +15,7 @@ import { basename } from 'vs/base/common/path';
import { isWindows } from 'vs/base/common/platform';
import { ISplice } from 'vs/base/common/sequence';
import { URI, UriComponents } from 'vs/base/common/uri';
import { ILineChange } from 'vs/editor/common/diff/diffComputer';
import { ILineChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import * as editorCommon from 'vs/editor/common/editorCommon';
import { Command, WorkspaceEditMetadata } from 'vs/editor/common/languages';
import { IReadonlyTextBuffer } from 'vs/editor/common/model';

View file

@ -50,7 +50,7 @@ import { Codicon } from 'vs/base/common/codicons';
import { onUnexpectedError } from 'vs/base/common/errors';
import { TextCompareEditorActiveContext } from 'vs/workbench/common/contextkeys';
import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress';
import { IChange } from 'vs/editor/common/diff/diffComputer';
import { IChange } from 'vs/editor/common/diff/smartLinesDiffComputer';
import { Color } from 'vs/base/common/color';
import { editorGutter } from 'vs/editor/common/core/editorColorRegistry';
import { Iterable } from 'vs/base/common/iterator';
@ -297,7 +297,8 @@ class DirtyDiffWidget extends PeekViewWidget {
minimap: { enabled: false },
renderSideBySide: false,
readOnly: false,
renderIndicators: false
renderIndicators: false,
diffAlgorithm: 'smart',
};
this.diffEditor = this.instantiationService.createInstance(EmbeddedDiffEditorWidget, container, options, this.editor);

View file

@ -836,6 +836,7 @@ const diffEditorOptions: IDiffEditorConstructionOptions = {
renderSideBySide: true,
originalAriaLabel: localize('testingOutputExpected', 'Expected result'),
modifiedAriaLabel: localize('testingOutputActual', 'Actual result'),
diffAlgorithm: 'smart',
};
const isDiffable = (message: ITestMessage): message is ITestErrorMessage & { actualOutput: string; expectedOutput: string } =>