Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<LSP.TextDocumentContentChangeEvent, LSP.TextDocumentContentChangeFullReplacementEvent>[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()
{
Expand Down Expand Up @@ -744,21 +747,30 @@ 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,
[.. changes]);
return ExecuteRequestAsync<LSP.DidChangeTextDocumentParams, object>(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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,7 +38,7 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(DidChangeTextDocumentPar
return SpecializedTasks.Default<object>();
}

internal static bool AreChangesInReverseOrder(TextDocumentContentChangeEvent[] contentChanges)
internal static bool AreChangesInReverseOrder(ImmutableArray<TextDocumentContentChangeEvent> contentChanges)
{
for (var i = 1; i < contentChanges.Length; i++)
{
Expand All @@ -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<TextDocumentContentChangeEvent, TextDocumentContentChangeFullReplacementEvent>[] contentChanges, SourceText text)
{
(var remainingContentChanges, text) = GetUpdatedSouorceTextAndChangesAfterFullTextReplacementHandled(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
Expand All @@ -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<TextDocumentContentChangeEvent>, SourceText) GetUpdatedSouorceTextAndChangesAfterFullTextReplacementHandled(SumType<TextDocumentContentChangeEvent, TextDocumentContentChangeFullReplacementEvent>[] contentChanges, SourceText text)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private static (ImmutableArray<TextDocumentContentChangeEvent>, SourceText) GetUpdatedSouorceTextAndChangesAfterFullTextReplacementHandled(SumType<TextDocumentContentChangeEvent, TextDocumentContentChangeFullReplacementEvent>[] contentChanges, SourceText text)
private static (ImmutableArray<TextDocumentContentChangeEvent>, SourceText) GetUpdatedSourceTextAndChangesAfterFullTextReplacementHandled(SumType<TextDocumentContentChangeEvent, TextDocumentContentChangeFullReplacementEvent>[] 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)]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doing the WithChanges versus just creating a new text from scratch?

Copy link
Contributor Author

@ToddGrun ToddGrun Apr 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't want to have to duplicate the parameter knowledge involved with doing that, as done here:

var sourceText = SourceText.From(request.TextDocument.Text, System.Text.Encoding.UTF8, SourceHashAlgorithms.OpenDocumentChecksumAlgorithm);

break;
}
}

var remainingContentChanges = contentChanges.Skip(lastFullTextChangeEventIndex + 1).SelectAsArray(c => c.First);
return (remainingContentChanges, text);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public VersionedTextDocumentIdentifier TextDocument
/// </summary>
[JsonPropertyName("contentChanges")]
[JsonRequired]
public TextDocumentContentChangeEvent[] ContentChanges
public SumType<TextDocumentContentChangeEvent, TextDocumentContentChangeFullReplacementEvent>[] ContentChanges
{
get;
set;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal sealed class TextDocumentContentChangeEvent
/// Gets or sets the range of the text that was changed.
/// </summary>
[JsonPropertyName("range")]
[JsonRequired]
public Range Range
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than messing with the SumTypes, could we have just made this nullable?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec defines it as a union type, so we should use a union type. They do happen to share the same field names right now, but that isn't a guarantee for the futre

{
get;
Expand All @@ -39,6 +40,7 @@ public int? RangeLength
/// Gets or sets the new text of the range/document.
/// </summary>
[JsonPropertyName("text")]
[JsonRequired]
public string Text
{
get;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Gets or sets the new text of the document.
/// </summary>
[JsonPropertyName("text")]
[JsonRequired]
public string Text
{
get;
set;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading