Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mapped edits helper #11146

Merged
merged 23 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -183,6 +183,7 @@ public static void AddTextDocumentServices(this IServiceCollection services, Lan
services.AddHandler<DocumentDidSaveEndpoint>();

services.AddHandler<RazorMapToDocumentRangesEndpoint>();
services.AddHandler<RazorMapToDocumentEditsEndpoint>();
services.AddHandler<RazorLanguageQueryEndpoint>();
}

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.AspNetCore.Razor.Utilities;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Mapping;

internal static partial class RazorEditHelper
{
/// <summary>
/// Maps the given text edits for a razor file based on changes in csharp. It special
/// cases usings directives to insure they are added correctly. All other edits
/// are applied if they map to the razor document.
/// </summary>
/// <returns></returns>
internal static async Task<ImmutableArray<TextChange>> MapCSharpEditsAsync(
ImmutableArray<TextChange> textEdits,
IDocumentSnapshot snapshot,
RazorCodeDocument codeDocument,
IDocumentMappingService documentMappingService,
ITelemetryReporter? telemetryReporter,
ryzngard marked this conversation as resolved.
Show resolved Hide resolved
CancellationToken cancellationToken)
{
using var textChangeBuilder = new TextChangeBuilder(documentMappingService);
var originalSyntaxTree = await snapshot.GetCSharpSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
var csharpSourceText = await originalSyntaxTree.GetTextAsync(cancellationToken).ConfigureAwait(false);
var newText = csharpSourceText.WithChanges(textEdits);
var newSyntaxTree = originalSyntaxTree.WithChangedText(newText);

textChangeBuilder.AddDirectlyMappedChanges(textEdits, codeDocument, cancellationToken);

var oldUsings = await AddUsingsHelper.FindUsingDirectiveStringsAsync(
originalSyntaxTree,
cancellationToken).ConfigureAwait(false);

var newUsings = await AddUsingsHelper.FindUsingDirectiveStringsAsync(
newSyntaxTree,
cancellationToken).ConfigureAwait(false);

var addedUsings = Delta.Compute(oldUsings, newUsings);
var removedUsings = Delta.Compute(newUsings, oldUsings);

textChangeBuilder.AddUsingsChanges(codeDocument, addedUsings, removedUsings, cancellationToken);

return NormalizeEdits(textChangeBuilder.DrainToOrderedImmutable(), telemetryReporter, cancellationToken);
}

/// <summary>
/// Go through edits and make sure a few things are true:
///
/// <list type="number">
/// <item>
/// No edit is added twice. This can happen if a rename happens.
/// </item>
/// <item>
/// No edit overlaps with another edit. If they do throw to capture logs but choose the first
/// edit to at least not completely fail. It's possible this will need to be tweaked later.
/// </item>
/// </list>
/// </summary>
private static ImmutableArray<TextChange> NormalizeEdits(ImmutableArray<TextChange> changes, ITelemetryReporter? telemetryReporter, CancellationToken cancellationToken)
{
// Ensure that the changes are sorted by start position otherwise
// the normalization logic will not work.
Debug.Assert(changes.SequenceEqual(changes.OrderBy(static c => c.Span.Start)));

using var normalizedEdits = new PooledArrayBuilder<TextChange>(changes.Length);
var remaining = changes.AsSpan();

var droppedEdits = 0;
while (remaining is not [])
{
cancellationToken.ThrowIfCancellationRequested();
if (remaining is [var edit, var nextEdit, ..])
{
if (edit.Span == nextEdit.Span)
{
normalizedEdits.Add(nextEdit);
remaining = remaining[1..];

if (edit.NewText != nextEdit.NewText)
{
droppedEdits++;
}
}
else if (edit.Span.Contains(nextEdit.Span))
{
// Cases where there was a removal and addition on the same
// line err to taking the addition. This can happen in the
// case of a namespace rename
if (edit.Span.Start == nextEdit.Span.Start)
{
if (string.IsNullOrEmpty(edit.NewText) && !string.IsNullOrEmpty(nextEdit.NewText))
{
// Don't count this as a dropped edit, it is expected
// in the case of a rename
normalizedEdits.Add(new TextChange(edit.Span, nextEdit.NewText));
remaining = remaining[1..];
}
else
{
normalizedEdits.Add(edit);
remaining = remaining[1..];
droppedEdits++;
}
}
else
{
normalizedEdits.Add(edit);

remaining = remaining[1..];
droppedEdits++;
}
}
else if (nextEdit.Span.Contains(edit.Span))
{
// Add the edit that is contained in the other edit
// and skip the next edit.
normalizedEdits.Add(nextEdit);
remaining = remaining[1..];
droppedEdits++;
}
else
{
normalizedEdits.Add(edit);
}
}
else
{
normalizedEdits.Add(remaining[0]);
}

remaining = remaining[1..];
}

if (droppedEdits > 0)
{
telemetryReporter?.ReportFault(
new DroppedEditsException(),
"Potentially dropped edits when trying to map",
new Property("droppedEditCount", droppedEdits));
}

return normalizedEdits.ToImmutable();
}

private sealed class DroppedEditsException : Exception
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Protocol.DocumentMapping;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Mapping;

[RazorLanguageServerEndpoint(LanguageServerConstants.RazorMapToDocumentEditsEndpoint)]
internal partial class RazorMapToDocumentEditsEndpoint(IDocumentMappingService documentMappingService, ITelemetryReporter telemetryReporter, ILoggerFactory loggerFactory) :
IRazorDocumentlessRequestHandler<RazorMapToDocumentEditsParams, RazorMapToDocumentEditsResponse?>,
ITextDocumentIdentifierHandler<RazorMapToDocumentRangesParams, Uri>
{
private readonly IDocumentMappingService _documentMappingService = documentMappingService;
private readonly ITelemetryReporter _telemetryReporter = telemetryReporter;
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<RazorMapToDocumentEditsEndpoint>();

public bool MutatesSolutionState => false;

public Uri GetTextDocumentIdentifier(RazorMapToDocumentRangesParams request)
{
return request.RazorDocumentUri;
}

public async Task<RazorMapToDocumentEditsResponse?> HandleRequestAsync(RazorMapToDocumentEditsParams request, RazorRequestContext requestContext, CancellationToken cancellationToken)
{
var documentContext = requestContext.DocumentContext;
if (documentContext is null)
{
return null;
}

if (request.TextEdits.Length == 0)
{
return null;
}

if (request.Kind != RazorLanguageKind.CSharp)
{
// All other non-C# requests map directly to where they are in the document,
// so the edits do as well
return new RazorMapToDocumentEditsResponse()
{
Edits = request.TextEdits,
HostDocumentVersion = documentContext.Snapshot.Version,
};
}

var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
if (codeDocument.IsUnsupported())
{
return null;
}

var mappedEdits = await RazorEditHelper.MapCSharpEditsAsync(
request.TextEdits.ToImmutableArray(),
documentContext.Snapshot,
codeDocument,
_documentMappingService,
_telemetryReporter,
cancellationToken).ConfigureAwait(false);

_logger.LogTrace($"""
Before:
{DisplayEdits(request.TextEdits)}

After:
{DisplayEdits(mappedEdits)}
""");

return new RazorMapToDocumentEditsResponse()
{
Edits = mappedEdits.ToArray(),
};
}

private string DisplayEdits(IEnumerable<TextChange> edits)
=> string.Join(
Environment.NewLine,
edits.Select(e => $"{e.Span} => '{e.NewText}'"));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Generic;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.CodeAnalysis.Razor;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Mapping;

internal sealed class UsingsNodeComparer : IComparer<RazorDirectiveSyntax>
{
public static readonly UsingsNodeComparer Instance = new();
public int Compare(RazorDirectiveSyntax? x, RazorDirectiveSyntax? y)
{
if (x is null)
{
return y is null ? 0 : -1;
}

if (y is null)
{
return 1;
}

RazorSyntaxFacts.TryGetNamespaceFromDirective(x, out var xNamespace);
RazorSyntaxFacts.TryGetNamespaceFromDirective(y, out var yNamespace);

return UsingsStringComparer.Instance.Compare(xNamespace, yNamespace);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Mapping;

internal sealed class UsingsStringComparer : IComparer<string>
{
public static readonly UsingsStringComparer Instance = new();

public int Compare(string? x, string? y)
{
if (x is null)
{
return y is null ? 0 : 1;
}

if (y is null)
{
return -1;
}

var xIsSystem = x.StartsWith("System", StringComparison.Ordinal);
var yIsSystem = y.StartsWith("System", StringComparison.Ordinal);

if (xIsSystem)
{
return yIsSystem
? string.Compare(x, y, StringComparison.Ordinal)
: -1;
}

if (yIsSystem)
{
return 1;
}

return string.Compare(x, y, StringComparison.Ordinal);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ public static LinePosition WithCharacter(this LinePosition linePosition, int new

public static LinePosition WithCharacter(this LinePosition linePosition, Func<int, int> computeNewCharacter)
=> new(linePosition.Line, computeNewCharacter(linePosition.Character));

public static int ToAbsolutePosition(this LinePosition linePosition, SourceText text)
=> text.Lines[linePosition.Line - 1].Start + linePosition.Character;
ryzngard marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
Expand Down Expand Up @@ -44,8 +45,10 @@ public static async Task<TextEdit[]> GetUsingStatementEditsAsync(RazorCodeDocume
// So because of the above, we look for a difference in C# using directive nodes directly from the C# syntax tree, and apply them manually
// to the Razor document.

var oldUsings = await FindUsingDirectiveStringsAsync(originalCSharpText, cancellationToken).ConfigureAwait(false);
var newUsings = await FindUsingDirectiveStringsAsync(changedCSharpText, cancellationToken).ConfigureAwait(false);
var originalCSharpSyntaxTree = CSharpSyntaxTree.ParseText(originalCSharpText);
var changedCSharpSyntaxTree = originalCSharpSyntaxTree.WithChangedText(changedCSharpText);
var oldUsings = await FindUsingDirectiveStringsAsync(originalCSharpSyntaxTree, cancellationToken).ConfigureAwait(false);
var newUsings = await FindUsingDirectiveStringsAsync(changedCSharpSyntaxTree, cancellationToken).ConfigureAwait(false);

using var edits = new PooledArrayBuilder<TextEdit>();
foreach (var usingStatement in newUsings.Except(oldUsings))
Expand Down Expand Up @@ -137,9 +140,8 @@ public static WorkspaceEdit CreateAddUsingWorkspaceEdit(string @namespace, TextD
};
}

private static async Task<IEnumerable<string>> FindUsingDirectiveStringsAsync(SourceText originalCSharpText, CancellationToken cancellationToken)
public static async Task<ImmutableArray<string>> FindUsingDirectiveStringsAsync(SyntaxTree syntaxTree, CancellationToken cancellationToken)
{
var syntaxTree = CSharpSyntaxTree.ParseText(originalCSharpText, cancellationToken: cancellationToken);
var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false);

// We descend any compilation unit (ie, the file) or and namespaces because the compiler puts all usings inside
Expand All @@ -153,7 +155,7 @@ private static async Task<IEnumerable<string>> FindUsingDirectiveStringsAsync(So
// we should still work in C# v26
.Select(u => u.ToString()["using ".Length..^1]);

return usings;
return usings.ToImmutableArray();
}

private static TextDocumentEdit GenerateSingleUsingEditsInterpolated(
Expand Down
Loading
Loading