diff --git a/src/LanguageServer/Protocol.TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs b/src/LanguageServer/Protocol.TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs index 711ee514b0a73..837fcf692fc9c 100644 --- a/src/LanguageServer/Protocol.TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs +++ b/src/LanguageServer/Protocol.TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs @@ -500,13 +500,16 @@ private static string GetDocumentFilePathFromName(string documentName) private static LSP.DidChangeTextDocumentParams CreateDidChangeTextDocumentParams( Uri documentUri, - ImmutableArray<(LSP.Range Range, string Text)> changes) + ImmutableArray<(LSP.Range? Range, string Text)> changes) { - var changeEvents = changes.Select(change => new LSP.TextDocumentContentChangeEvent + var changeEvents = new LSP.SumType[changes.Length]; + for (var i = 0; i < changes.Length; i++) { - Text = change.Text, - Range = change.Range, - }).ToArray(); + var (range, text) = changes[i]; + changeEvents[i] = range != null + ? new LSP.TextDocumentContentChangeEvent { Text = text, Range = range } + : new LSP.TextDocumentContentChangeFullReplacementEvent { Text = text }; + } return new LSP.DidChangeTextDocumentParams() { @@ -744,7 +747,7 @@ public async Task OpenDocumentInWorkspaceAsync(DocumentId documentId, bool openA await WaitForWorkspaceOperationsAsync(TestWorkspace); } - public Task ReplaceTextAsync(Uri documentUri, params (LSP.Range Range, string Text)[] changes) + public Task ReplaceTextAsync(Uri documentUri, params (LSP.Range? Range, string Text)[] changes) { var didChangeParams = CreateDidChangeTextDocumentParams( documentUri, @@ -752,13 +755,22 @@ public Task ReplaceTextAsync(Uri documentUri, params (LSP.Range Range, string Te return ExecuteRequestAsync(LSP.Methods.TextDocumentDidChangeName, didChangeParams, CancellationToken.None); } - public Task InsertTextAsync(Uri documentUri, params (int Line, int Column, string Text)[] changes) + public Task InsertTextAsync(Uri documentUri, params (int? Line, int? Column, string Text)[] changes) { - return ReplaceTextAsync(documentUri, [.. changes.Select(change => (new LSP.Range + var rangeChanges = new List<(LSP.Range? Range, string Text)>(); + foreach (var (line, column, text) in changes) { - Start = new LSP.Position { Line = change.Line, Character = change.Column }, - End = new LSP.Position { Line = change.Line, Character = change.Column } - }, change.Text))]); + var range = (line is null || column is null) + ? null + : new LSP.Range + { + Start = new LSP.Position { Line = line.Value, Character = column.Value }, + End = new LSP.Position { Line = line.Value, Character = column.Value } + }; + rangeChanges.Add((range, text)); + } + + return ReplaceTextAsync(documentUri, [.. rangeChanges]); } public Task DeleteTextAsync(Uri documentUri, params (int StartLine, int StartColumn, int EndLine, int EndColumn)[] changes) diff --git a/src/LanguageServer/Protocol/Handler/DocumentChanges/DidChangeHandler.cs b/src/LanguageServer/Protocol/Handler/DocumentChanges/DidChangeHandler.cs index b13b4ae983006..b1614a7bc7c9d 100644 --- a/src/LanguageServer/Protocol/Handler/DocumentChanges/DidChangeHandler.cs +++ b/src/LanguageServer/Protocol/Handler/DocumentChanges/DidChangeHandler.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Immutable; using System.Composition; using System.Linq; using System.Threading; @@ -37,7 +38,7 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(DidChangeTextDocumentPar return SpecializedTasks.Default(); } - internal static bool AreChangesInReverseOrder(TextDocumentContentChangeEvent[] contentChanges) + internal static bool AreChangesInReverseOrder(ImmutableArray contentChanges) { for (var i = 1; i < contentChanges.Length; i++) { @@ -53,8 +54,14 @@ internal static bool AreChangesInReverseOrder(TextDocumentContentChangeEvent[] c return true; } - private static SourceText GetUpdatedSourceText(TextDocumentContentChangeEvent[] contentChanges, SourceText text) + private static SourceText GetUpdatedSourceText(SumType[] contentChanges, SourceText text) { + (var remainingContentChanges, text) = GetUpdatedSourceTextAndChangesAfterFullTextReplacementHandled(contentChanges, text); + + // No range-based changes to apply. + if (remainingContentChanges.IsEmpty) + return text; + // Per the LSP spec, each text change builds upon the previous, so we don't need to translate any text // positions between changes. See // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#didChangeTextDocumentParams @@ -63,20 +70,41 @@ private static SourceText GetUpdatedSourceText(TextDocumentContentChangeEvent[] // If the host sends us changes in a way such that no earlier change can affect the position of a later change, // then we can merge the changes into a single TextChange, allowing creation of only a single new // source text. - if (AreChangesInReverseOrder(contentChanges)) + if (AreChangesInReverseOrder(remainingContentChanges)) { // The changes were in reverse document order, so we can merge them into a single operation on the source text. // Note that the WithChanges implementation works more efficiently with it's input in forward document order. - var newChanges = contentChanges.Reverse().SelectAsArray(change => ProtocolConversions.ContentChangeEventToTextChange(change, text)); + var newChanges = remainingContentChanges.Reverse().SelectAsArray(change => ProtocolConversions.ContentChangeEventToTextChange(change, text)); text = text.WithChanges(newChanges); } else { // The host didn't send us the items ordered, so we'll apply each one independently. - foreach (var change in contentChanges) + foreach (var change in remainingContentChanges) text = text.WithChanges(ProtocolConversions.ContentChangeEventToTextChange(change, text)); } return text; } + + private static (ImmutableArray, SourceText) GetUpdatedSourceTextAndChangesAfterFullTextReplacementHandled(SumType[] contentChanges, SourceText text) + { + // Per the LSP spec, each content change can be either a TextDocumentContentChangeEvent or TextDocumentContentChangeFullReplacementEvent. + // The former is a range-based change while the latter is a full text replacement. If a TextDocumentContentChangeFullReplacementEvent is found, + // then make a full text replacement for that and return all subsequent changes as remaining range-based changes. + var lastFullTextChangeEventIndex = contentChanges.Length - 1; + for (; lastFullTextChangeEventIndex >= 0; lastFullTextChangeEventIndex--) + { + var change = contentChanges[lastFullTextChangeEventIndex]; + if (change.Value is TextDocumentContentChangeFullReplacementEvent onlyTextEvent) + { + // Found a full text replacement. Create the new text and stop processing. + text = text.WithChanges([new TextChange(new TextSpan(0, text.Length), onlyTextEvent.Text)]); + break; + } + } + + var remainingContentChanges = contentChanges.Skip(lastFullTextChangeEventIndex + 1).SelectAsArray(c => c.First); + return (remainingContentChanges, text); + } } diff --git a/src/LanguageServer/Protocol/Protocol/DidChangeTextDocumentParams.cs b/src/LanguageServer/Protocol/Protocol/DidChangeTextDocumentParams.cs index d1cc7c57bd502..8c4003df27488 100644 --- a/src/LanguageServer/Protocol/Protocol/DidChangeTextDocumentParams.cs +++ b/src/LanguageServer/Protocol/Protocol/DidChangeTextDocumentParams.cs @@ -45,7 +45,7 @@ public VersionedTextDocumentIdentifier TextDocument /// [JsonPropertyName("contentChanges")] [JsonRequired] - public TextDocumentContentChangeEvent[] ContentChanges + public SumType[] ContentChanges { get; set; diff --git a/src/LanguageServer/Protocol/Protocol/TextDocumentContentChangeEvent.cs b/src/LanguageServer/Protocol/Protocol/TextDocumentContentChangeEvent.cs index e8a74e7181f22..fa2641f426b13 100644 --- a/src/LanguageServer/Protocol/Protocol/TextDocumentContentChangeEvent.cs +++ b/src/LanguageServer/Protocol/Protocol/TextDocumentContentChangeEvent.cs @@ -18,6 +18,7 @@ internal sealed class TextDocumentContentChangeEvent /// Gets or sets the range of the text that was changed. /// [JsonPropertyName("range")] + [JsonRequired] public Range Range { get; @@ -39,6 +40,7 @@ public int? RangeLength /// Gets or sets the new text of the range/document. /// [JsonPropertyName("text")] + [JsonRequired] public string Text { get; diff --git a/src/LanguageServer/Protocol/Protocol/TextDocumentContentChangeFullReplacementEvent.cs b/src/LanguageServer/Protocol/Protocol/TextDocumentContentChangeFullReplacementEvent.cs new file mode 100644 index 0000000000000..64ec2d4d84c91 --- /dev/null +++ b/src/LanguageServer/Protocol/Protocol/TextDocumentContentChangeFullReplacementEvent.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Roslyn.LanguageServer.Protocol; + +using System.Text.Json.Serialization; + +internal sealed class TextDocumentContentChangeFullReplacementEvent +{ + /// + /// Gets or sets the new text of the document. + /// + [JsonPropertyName("text")] + [JsonRequired] + public string Text + { + get; + set; + } +} diff --git a/src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs b/src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs index 9d1619a1f4473..ebf95f3196258 100644 --- a/src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs @@ -448,6 +448,40 @@ void M() } } + [Theory, CombinatorialData] + public async Task DidChange_MultipleRequestsIncludingTextOnly(bool mutatingLspWorkspace) + { + var source = + """ + {|type:|} + """; + var expected = + """ + /* test */ + """; + + var (testLspServer, locationTyped, _) = await GetTestLspServerAndLocationAsync(source, mutatingLspWorkspace); + + await using (testLspServer) + { + await DidOpen(testLspServer, locationTyped.Uri); + + var changes = new (int? line, int? column, string text)[] + { + (0, 0, "// hi"), + (null, null, "/* */"), + (0, 3, "test"), + }; + + await DidChange(testLspServer, locationTyped.Uri, changes); + + var document = testLspServer.GetTrackedTexts().FirstOrDefault(); + + AssertEx.NotNull(document); + Assert.Equal(expected, document.ToString()); + } + } + private async Task<(TestLspServer, LSP.Location, string)> GetTestLspServerAndLocationAsync(string source, bool mutatingLspWorkspace) { var testLspServer = await CreateTestLspServerAsync(source, mutatingLspWorkspace, CapabilitiesWithVSExtensions); @@ -459,7 +493,7 @@ void M() private static Task DidOpen(TestLspServer testLspServer, Uri uri) => testLspServer.OpenDocumentAsync(uri); - private static async Task DidChange(TestLspServer testLspServer, Uri uri, params (int line, int column, string text)[] changes) + private static async Task DidChange(TestLspServer testLspServer, Uri uri, params (int? line, int? column, string text)[] changes) => await testLspServer.InsertTextAsync(uri, changes); private static async Task DidClose(TestLspServer testLspServer, Uri uri) => await testLspServer.CloseDocumentAsync(uri);