Skip to content

Commit b96c245

Browse files
authored
Merge changes from a single DidChange notification (#74268)
* Merge changes from a single DidChange notification I'm currently investigating a customer profile where didChange notifications are showing very prevalently in CPU time. I've create this PR (#74267) to help with the underlying performance of creating the SourceText, however, there would still be potentially many source texts created for a single operation. This PR attempts to alleviate that situation by changing the DidChange handler to create a single source text on the input TextDocumentContentChangeEvent array. This array can be quite large, such as when the user does a replace-all or multi-line edit in VS. Roughly 50% of CPU time in the customer profile was spent in our DidChange handler (about 29 seconds of CPU time).
1 parent ae38512 commit b96c245

File tree

3 files changed

+147
-8
lines changed

3 files changed

+147
-8
lines changed

src/LanguageServer/Protocol/Extensions/Extensions.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,5 +243,20 @@ private static bool TryGetVSCompletionListSetting(ClientCapabilities clientCapab
243243

244244
return true;
245245
}
246+
247+
public static int CompareTo(this Position p1, Position p2)
248+
{
249+
if (p1.Line > p2.Line)
250+
return 1;
251+
else if (p1.Line < p2.Line)
252+
return -1;
253+
254+
if (p1.Character > p2.Character)
255+
return 1;
256+
else if (p1.Character < p2.Character)
257+
return -1;
258+
259+
return 0;
260+
}
246261
}
247262
}

src/LanguageServer/Protocol/Handler/DocumentChanges/DidChangeHandler.cs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
using System;
66
using System.Composition;
7+
using System.Linq;
78
using System.Threading;
89
using System.Threading.Tasks;
910
using Microsoft.CodeAnalysis.Host.Mef;
11+
using Microsoft.CodeAnalysis.Text;
1012
using Roslyn.LanguageServer.Protocol;
1113
using Roslyn.Utilities;
1214

@@ -28,15 +30,53 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(DidChangeTextDocumentPar
2830
{
2931
var text = context.GetTrackedDocumentSourceText(request.TextDocument.Uri);
3032

31-
// Per the LSP spec, each text change builds upon the previous, so we don't need to translate any text
32-
// positions between changes, which makes this quite easy. See
33-
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#didChangeTextDocumentParams
34-
// for more details.
35-
foreach (var change in request.ContentChanges)
36-
text = text.WithChanges(ProtocolConversions.ContentChangeEventToTextChange(change, text));
33+
text = GetUpdatedSourceText(request.ContentChanges, text);
3734

3835
context.UpdateTrackedDocument(request.TextDocument.Uri, text);
3936

4037
return SpecializedTasks.Default<object>();
4138
}
39+
40+
internal static bool AreChangesInReverseOrder(TextDocumentContentChangeEvent[] contentChanges)
41+
{
42+
for (var i = 1; i < contentChanges.Length; i++)
43+
{
44+
var prevChange = contentChanges[i - 1];
45+
var curChange = contentChanges[i];
46+
47+
if (prevChange.Range.Start.CompareTo(curChange.Range.End) < 0)
48+
{
49+
return false;
50+
}
51+
}
52+
53+
return true;
54+
}
55+
56+
private static SourceText GetUpdatedSourceText(TextDocumentContentChangeEvent[] contentChanges, SourceText text)
57+
{
58+
// Per the LSP spec, each text change builds upon the previous, so we don't need to translate any text
59+
// positions between changes. See
60+
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#didChangeTextDocumentParams
61+
// for more details.
62+
//
63+
// If the host sends us changes in a way such that no earlier change can affect the position of a later change,
64+
// then we can merge the changes into a single TextChange, allowing creation of only a single new
65+
// source text.
66+
if (AreChangesInReverseOrder(contentChanges))
67+
{
68+
// The changes were in reverse document order, so we can merge them into a single operation on the source text.
69+
// Note that the WithChanges implementation works more efficiently with it's input in forward document order.
70+
var newChanges = contentChanges.Reverse().SelectAsArray(change => ProtocolConversions.ContentChangeEventToTextChange(change, text));
71+
text = text.WithChanges(newChanges);
72+
}
73+
else
74+
{
75+
// The host didn't send us the items ordered, so we'll apply each one independently.
76+
foreach (var change in contentChanges)
77+
text = text.WithChanges(ProtocolConversions.ContentChangeEventToTextChange(change, text));
78+
}
79+
80+
return text;
81+
}
4282
}

src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System;
66
using System.Linq;
77
using System.Threading.Tasks;
8+
using Microsoft.CodeAnalysis.LanguageServer.Handler.DocumentChanges;
89
using Roslyn.Test.Utilities;
910
using Xunit;
1011
using Xunit.Abstractions;
@@ -245,7 +246,7 @@ void M()
245246
}
246247

247248
[Theory, CombinatorialData]
248-
public async Task DidChange_MultipleChanges1(bool mutatingLspWorkspace)
249+
public async Task DidChange_MultipleChanges_ForwardOrder(bool mutatingLspWorkspace)
249250
{
250251
var source =
251252
"""
@@ -285,7 +286,7 @@ void M()
285286
}
286287

287288
[Theory, CombinatorialData]
288-
public async Task DidChange_MultipleChanges2(bool mutatingLspWorkspace)
289+
public async Task DidChange_MultipleChanges_Overlapping(bool mutatingLspWorkspace)
289290
{
290291
var source =
291292
"""
@@ -323,6 +324,89 @@ void M()
323324
}
324325
}
325326

327+
[Theory, CombinatorialData]
328+
public async Task DidChange_MultipleChanges_ReverseOrder(bool mutatingLspWorkspace)
329+
{
330+
var source =
331+
"""
332+
class A
333+
{
334+
void M()
335+
{
336+
{|type:|}
337+
}
338+
}
339+
""";
340+
var expected =
341+
"""
342+
class A
343+
{
344+
void M()
345+
{
346+
// hi there
347+
// this builds on that
348+
}
349+
}
350+
""";
351+
352+
var (testLspServer, locationTyped, _) = await GetTestLspServerAndLocationAsync(source, mutatingLspWorkspace);
353+
354+
await using (testLspServer)
355+
{
356+
await DidOpen(testLspServer, locationTyped.Uri);
357+
358+
await DidChange(testLspServer, locationTyped.Uri, (5, 0, " // this builds on that\r\n"), (4, 8, "// hi there"));
359+
360+
var document = testLspServer.GetTrackedTexts().FirstOrDefault();
361+
362+
AssertEx.NotNull(document);
363+
Assert.Equal(expected, document.ToString());
364+
}
365+
}
366+
367+
private LSP.TextDocumentContentChangeEvent CreateTextDocumentContentChangeEvent(int startLine, int startCol, int endLine, int endCol, string newText)
368+
{
369+
return new LSP.TextDocumentContentChangeEvent()
370+
{
371+
Range = new LSP.Range()
372+
{
373+
Start = new LSP.Position(startLine, startCol),
374+
End = new LSP.Position(endLine, endCol)
375+
},
376+
Text = newText
377+
};
378+
}
379+
380+
[Fact]
381+
public void DidChange_AreChangesInReverseOrder_True()
382+
{
383+
LSP.TextDocumentContentChangeEvent change1 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 7, endLine: 0, endCol: 9, newText: "test3");
384+
LSP.TextDocumentContentChangeEvent change2 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 5, endLine: 0, endCol: 7, newText: "test2");
385+
LSP.TextDocumentContentChangeEvent change3 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 1, endLine: 0, endCol: 3, newText: "test1");
386+
387+
Assert.True(DidChangeHandler.AreChangesInReverseOrder([change1, change2, change3]));
388+
}
389+
390+
[Fact]
391+
public void DidChange_AreChangesInReverseOrder_InForwardOrder()
392+
{
393+
LSP.TextDocumentContentChangeEvent change1 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 1, endLine: 0, endCol: 3, newText: "test1");
394+
LSP.TextDocumentContentChangeEvent change2 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 5, endLine: 0, endCol: 7, newText: "test2");
395+
LSP.TextDocumentContentChangeEvent change3 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 7, endLine: 0, endCol: 9, newText: "test3");
396+
397+
Assert.False(DidChangeHandler.AreChangesInReverseOrder([change1, change2, change3]));
398+
}
399+
400+
[Fact]
401+
public void DidChange_AreChangesInReverseOrder_Overlapping()
402+
{
403+
LSP.TextDocumentContentChangeEvent change1 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 1, endLine: 0, endCol: 3, newText: "test1");
404+
LSP.TextDocumentContentChangeEvent change2 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 2, endLine: 0, endCol: 4, newText: "test2");
405+
LSP.TextDocumentContentChangeEvent change3 = CreateTextDocumentContentChangeEvent(startLine: 0, startCol: 3, endLine: 0, endCol: 5, newText: "test3");
406+
407+
Assert.False(DidChangeHandler.AreChangesInReverseOrder([change1, change2, change3]));
408+
}
409+
326410
[Theory, CombinatorialData]
327411
public async Task DidChange_MultipleRequests(bool mutatingLspWorkspace)
328412
{

0 commit comments

Comments
 (0)