Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -13,6 +13,7 @@
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.CodeAnalysis.Remote.Razor.DocumentMapping;

Expand All @@ -33,6 +34,12 @@ public async Task MapWorkspaceEditAsync(IDocumentSnapshot contextDocumentSnapsho
if (edit.TryGetFirst(out var textDocumentEdit))
{
await MapTextDocumentEditAsync(originSnapshot, textDocumentEdit, cancellationToken).ConfigureAwait(false);

// Roslyn may return edits that already target the real Razor document when it maps
// source-generated documents itself. If both generated C# documents contribute the
// same Razor edit, keep a single edit so applying the workspace edit doesn't fail
// with overlapping changes.
await DeduplicateTextDocumentEditAsync(originSnapshot.TextDocument.Project.Solution, textDocumentEdit, cancellationToken).ConfigureAwait(false);
if (textDocumentEdit.Edits.Length == 0)
{
continue;
Expand Down Expand Up @@ -160,6 +167,71 @@ private async Task<ImmutableArray<TextEdit>> GetMappedTextEditsAsync(RemoteDocum
var textChanges = edits.SelectAsArray(csharpSourceText.GetRazorTextChange);
var mappedEdits = await MapCSharpEditsAsync(textChanges, snapshot, csharpDocument.IsDeclarationDocument, includeCSharpLanguageFeatureEdits: true, directlyMappedEditFilter: null, cancellationToken).ConfigureAwait(false);

return mappedEdits.SelectAsArray(razorSourceText.GetTextEdit);
// Multiple generated C# edits can map to the same Razor span, especially when rename touches
// both implementation and declaration generated documents. SourceText.WithChanges treats exact
// duplicate edits as overlapping, so collapse them after mapping back to Razor coordinates.
return Deduplicate(razorSourceText, mappedEdits.SelectAsArray(razorSourceText.GetTextEdit));
}

private static async Task DeduplicateTextDocumentEditAsync(Solution solution, TextDocumentEdit entry, CancellationToken cancellationToken)
{
if (entry.Edits.Length <= 1)
{
return;
}

var document = await solution.GetTextDocumentAsync(entry.TextDocument, cancellationToken).ConfigureAwait(false);
if (document is null)
{
return;
}

var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
entry.Edits = Deduplicate(sourceText, entry.Edits);
Comment thread
davidwengier marked this conversation as resolved.
Resolved
}

private static ImmutableArray<TextEdit> Deduplicate(SourceText sourceText, ImmutableArray<TextEdit> edits)
{
if (edits.Length <= 1)
{
return edits;
}

using var _ = HashSetPool<(int Start, int Length, string? NewText)>.GetPooledObject(out var seenEdits);
using var builder = new PooledArrayBuilder<TextEdit>(edits.Length);

foreach (var edit in edits)
{
var change = sourceText.GetTextChange(edit);
if (seenEdits.Add((change.Span.Start, change.Span.Length, change.NewText)))
Comment thread
davidwengier marked this conversation as resolved.
Resolved
{
builder.Add(edit);
}
}

return builder.ToImmutableAndClear();
}

private static SumType<TextEdit, AnnotatedTextEdit>[] Deduplicate(SourceText sourceText, SumType<TextEdit, AnnotatedTextEdit>[] edits)
{
if (edits.Length <= 1)
{
return edits;
}

using var _ = HashSetPool<(int Start, int Length, string? NewText)>.GetPooledObject(out var seenEdits);
using var builder = new PooledArrayBuilder<SumType<TextEdit, AnnotatedTextEdit>>(edits.Length);

foreach (var edit in edits)
{
var textEdit = (TextEdit)edit;
var change = sourceText.GetTextChange(textEdit);
if (seenEdits.Add((change.Span.Start, change.Span.Length, change.NewText)))
{
builder.Add(edit);
}
}
Comment thread
davidwengier marked this conversation as resolved.
Resolved

return builder.ToArrayAndClear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ protected override IRemoteRenameService CreateService(in ServiceArgs args)
var positionInfo = GetPositionInfo(codeDocument, hostDocumentIndex, preferCSharpOverHtml: true);

var generatedDocument = await context.Snapshot
.GetGeneratedDocumentAsync(cancellationToken)
.GetGeneratedDocumentAsync(positionInfo.InDeclDocument, cancellationToken)
.ConfigureAwait(false);

var razorEdit = await _renameService
Expand Down Expand Up @@ -134,7 +134,7 @@ protected override IRemoteRenameService CreateService(in ServiceArgs args)
return RemoteResponse<LspRange?>.CallHtml;
}

var generatedDocument = await context.Snapshot.GetGeneratedDocumentAsync(cancellationToken).ConfigureAwait(false);
var generatedDocument = await context.Snapshot.GetGeneratedDocumentAsync(positionInfo.InDeclDocument, cancellationToken).ConfigureAwait(false);

var csharpRange = await PrepareRenameHandler.GetRenameRangeAsync(generatedDocument, positionInfo.Position.ToLinePosition(), cancellationToken).ConfigureAwait(false);

Expand All @@ -143,7 +143,7 @@ protected override IRemoteRenameService CreateService(in ServiceArgs args)
return RemoteResponse<LspRange?>.NoFurtherHandling;
}

if (!DocumentMappingService.TryMapToRazorDocumentRange(codeDocument.GetRequiredImplCSharpDocument(), csharpRange, out var mappedRange))
if (!DocumentMappingService.TryMapToRazorDocumentRange(codeDocument.GetRequiredCSharpDocument(positionInfo.InDeclDocument), csharpRange, out var mappedRange))
{
return RemoteResponse<LspRange?>.NoFurtherHandling;
}
Expand Down Expand Up @@ -228,7 +228,11 @@ protected override IRemoteRenameService CreateService(in ServiceArgs args)
return null;
}

var generatedDocument = await context.Snapshot.GetGeneratedDocumentAsync(cancellationToken).ConfigureAwait(false);
// Component file rename updates the generated component class declaration, which lives in the
// declaration document under SONIC. Fall back to implementation for legacy documents that do
// not have a separate declaration document.
var generatedDocument = await context.Snapshot.TryGetGeneratedDocumentAsync(declarationDocument: true, cancellationToken).ConfigureAwait(false)
?? await context.Snapshot.GetGeneratedDocumentAsync(declarationDocument: false, cancellationToken).ConfigureAwait(false);
Comment thread
davidwengier marked this conversation as resolved.
Resolved
var text = await generatedDocument.GetTextAsync(cancellationToken).ConfigureAwait(false);
var tree = await generatedDocument.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
var declaration = tree.AssumeNotNull().DescendantNodes().OfType<ClassDeclarationSyntax>().FirstOrDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;

public class CohostPrepareRenameEndpointTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper)
{
[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task CSharp_Method()
=> VerifyPrepareRenameAsync(
input: """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;
[Collection(nameof(CohostRenameEndpointTest))]
public class CohostRenameEndpointTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper)
{
[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task CSharp_SameFile()
=> VerifyRenamesAsync(
input: """
Expand All @@ -41,21 +41,56 @@ The end.
newName: "CallThisFunction",
expected: """
This is a Razor document.

<h1>@CallThisFunction()</h1>

@code
{
public string CallThisFunction()
{
return $"Hi from {nameof(CallThisFunction)}";
}
}


The end.
""");

[Fact]
public Task CSharp_SameFile_FromMethodBody()
=> VerifyRenamesAsync(
input: """
This is a Razor document.

<h1>@MyMethod()</h1>

@code
{
public string MyMethod()
{
return MyMe$$thod();
}
}

The end.
""",
newName: "CallThisFunction",
expected: """
This is a Razor document.

<h1>@CallThisFunction()</h1>

@code
{
public string CallThisFunction()
{
return CallThisFunction();
}
}

The end.
""");

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task CSharp_WithOtherFile()
=> VerifyRenamesAsync(
input: """
Expand Down Expand Up @@ -116,7 +151,7 @@ public void CallThisFunction()
""")
]);

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task CSharp_Inherits()
=> VerifyRenamesAsync(
input: """
Expand Down Expand Up @@ -187,7 +222,7 @@ public class OtherModel
],
fileKind: RazorFileKind.Legacy);

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task CSharp_Implements()
=> VerifyRenamesAsync(
input: """
Expand Down Expand Up @@ -220,7 +255,7 @@ public interface IMyFace
""")
]);

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task CSharp_TypeParam()
=> VerifyRenamesAsync(
input: """
Expand Down Expand Up @@ -253,7 +288,50 @@ public interface IMyFace
""")
]);

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task CSharp_TypeParameter_FromDirective()
=> VerifyRenamesAsync(
input: """
@typeparam TI$$tem

This is a Razor document.

<h1>@typeof(TItem).Name</h1>

@code
{
private TItem? _item;

public TItem? GetItem()
{
return _item;
}
}

The end.
""",
newName: "TModel",
expected: """
@typeparam TModel

This is a Razor document.

<h1>@typeof(TModel).Name</h1>

@code
{
private TModel? _item;

public TModel? GetItem()
{
return _item;
}
}

The end.
""");

[Fact]
public Task CSharp_Attribute()
=> VerifyRenamesAsync(
input: """
Expand Down Expand Up @@ -286,7 +364,7 @@ public class HasJacketAttribute : Attribute
""")
]);
Comment thread
davidwengier marked this conversation as resolved.
Resolved

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task CSharp_Attribute_FullName()
=> VerifyRenamesAsync(
input: """
Comment thread
davidwengier marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -385,7 +463,7 @@ The end.
additionalExpectedFiles:
[(FileUri("DifferentName.razor"), "")]);

[Theory(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Theory]
[InlineData("My.$$Foo.Component")]
[InlineData("My.F$$oo.Component")]
[InlineData("My.Foo$$.Component")]
Expand Down Expand Up @@ -448,7 +526,7 @@ The end.
@namespace My.DifferentName
""")]);

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task Component_CSharp_FullyQualified_Namespace()
=> VerifyRenamesAsync(
input: $"""
Expand Down Expand Up @@ -624,7 +702,7 @@ The end.
additionalExpectedFiles:
[(FileUri("DifferentName.razor"), "")]);

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task Component_Attribute()
=> VerifyRenamesAsync(
input: $"""
Expand Down Expand Up @@ -687,7 +765,47 @@ The end.
""")
]);

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task Component_Attribute_FromParameterDeclaration()
=> VerifyRenamesAsync(
input: """
This is a Razor document.

<p>@Title</p>

@code {
[Parameter]
public string Tit$$le { get; set; } = nameof(Title);
}
""",
additionalFiles: [
(FilePath("OtherComponent.razor"), """
<Component Title="Hello1" />
<Component Title="Hello2">
</Component>
""")
],
newName: "Name",
expected: """
This is a Razor document.

<p>@Name</p>

@code {
[Parameter]
public string Name { get; set; } = nameof(Name);
}
""",
additionalExpectedFiles: [
(FileUri("OtherComponent.razor"), """
<Component Name="Hello1" />
<Component Name="Hello2">
</Component>
""")
],
documentFilePath: FilePath("Component.razor"));

[Fact]
public Task Component_BindAttribute()
=> VerifyRenamesAsync(
input: $"""
Expand Down Expand Up @@ -1215,7 +1333,7 @@ public class DifferentName : ComponentBase
""")
]);

[Fact(Skip = "PROTOTYPE(sonic): cohosting feature not yet decl/impl split aware; see PR #83887")]
[Fact]
public Task Component_Namespace_WithNonRazorCSharpFile()
=> VerifyRenamesAsync(
input: $"""
Expand Down Expand Up @@ -1462,11 +1580,12 @@ private async Task VerifyRenamesAsync(
string expected,
RazorFileKind? fileKind = null,
DocumentUri? newFileUri = null,
string? documentFilePath = null,
(string fileName, string contents)[]? additionalFiles = null,
(DocumentUri fileUri, string contents)[]? additionalExpectedFiles = null)
{
TestFileMarkupParser.GetPosition(input, out var source, out var cursorPosition);
var document = CreateProjectAndRazorDocument(source, fileKind, additionalFiles: additionalFiles);
var document = CreateProjectAndRazorDocument(source, fileKind, documentFilePath: documentFilePath, additionalFiles: additionalFiles);
var inputText = await document.GetTextAsync(DisposalToken);
var position = inputText.GetPosition(cursorPosition);

Expand Down
Loading