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

Implement handling of rename intent #59410

Merged
merged 6 commits into from
Apr 7, 2022
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -140,7 +140,7 @@ public C(int someInt)
}
}";

await VerifyExpectedTextAsync(WellKnownIntents.GenerateConstructor, initialText, additionalDocuments, expectedText).ConfigureAwait(false);
await VerifyExpectedTextAsync(WellKnownIntents.GenerateConstructor, initialText, additionalDocuments, new[] { expectedText }).ConfigureAwait(false);
}

[Fact]
Expand Down
64 changes: 44 additions & 20 deletions src/EditorFeatures/CSharpTest/Intents/IntentTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
Expand All @@ -12,6 +14,8 @@
using Microsoft.CodeAnalysis.Editor.UnitTests.Workspaces;
using Microsoft.CodeAnalysis.ExternalAccess.IntelliCode.Api;
using Microsoft.CodeAnalysis.Features.Intents;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Test.Utilities;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
Expand All @@ -22,12 +26,25 @@ namespace Microsoft.CodeAnalysis.Editor.CSharp.UnitTests.Intents
{
public class IntentTestsBase
{
internal static Task VerifyExpectedTextAsync(string intentName, string markup, string expectedText, OptionsCollection? options = null, string? intentData = null)
internal static Task VerifyExpectedTextAsync(
string intentName,
string markup,
string expectedText,
OptionsCollection? options = null,
string? intentData = null,
string? priorText = null)
{
return VerifyExpectedTextAsync(intentName, markup, new string[] { }, expectedText, options, intentData);
return VerifyExpectedTextAsync(intentName, markup, new string[] { }, new string[] { expectedText }, options, intentData, priorText);
}

internal static async Task VerifyExpectedTextAsync(string intentName, string activeDocument, string[] additionalDocuments, string expectedText, OptionsCollection? options = null, string? intentData = null)
internal static async Task VerifyExpectedTextAsync(
string intentName,
string activeDocument,
string[] additionalDocuments,
string[] expectedTexts,
OptionsCollection? options = null,
string? intentData = null,
string? priorText = null)
{
var documentSet = additionalDocuments.Prepend(activeDocument).ToArray();
using var workspace = TestWorkspace.CreateCSharp(documentSet, exportProvider: EditorTestCompositions.EditorFeatures.ExportProviderFactory.CreateExportProvider());
Expand All @@ -41,19 +58,13 @@ internal static async Task VerifyExpectedTextAsync(string intentName, string act
// The first document will be the active document.
var document = workspace.Documents.Single(d => d.Name == "test1.cs");
var textBuffer = document.GetTextBuffer();
var typedSpan = document.AnnotatedSpans["typed"].Single();

// Get the current snapshot span and selection.
var currentSelectedSpan = document.SelectedSpans.FirstOrDefault();
if (currentSelectedSpan.IsEmpty)
{
currentSelectedSpan = TextSpan.FromBounds(typedSpan.End, typedSpan.End);
}
// Get the text change to rewind the document to the correct pre-intent location.
var rewindTextChange = new TextChange(document.AnnotatedSpans["typed"].Single(), priorText ?? string.Empty);

var currentSnapshotSpan = new SnapshotSpan(textBuffer.CurrentSnapshot, currentSelectedSpan.ToSpan());
// Get the current snapshot span to pass in.
var currentSnapshot = new SnapshotSpan(textBuffer.CurrentSnapshot, new Span(0, textBuffer.CurrentSnapshot.Length));

// Determine the edits to rewind to the prior snapshot by removing the changes in the annotated span.
var rewindTextChange = new TextChange(typedSpan, "");
var priorSelection = TextSpan.FromBounds(rewindTextChange.Span.Start, rewindTextChange.Span.Start);
if (document.AnnotatedSpans.ContainsKey("priorSelection"))
{
Expand All @@ -62,7 +73,7 @@ internal static async Task VerifyExpectedTextAsync(string intentName, string act

var intentContext = new IntentRequestContext(
intentName,
currentSnapshotSpan,
currentSnapshot,
ImmutableArray.Create(rewindTextChange),
priorSelection,
intentData: intentData);
Expand All @@ -71,15 +82,28 @@ internal static async Task VerifyExpectedTextAsync(string intentName, string act
// For now, we're just taking the first result to match intellicode behavior.
var result = results.First();

using var edit = textBuffer.CreateEdit();
foreach (var change in result.TextChanges)
var actualDocumentTexts = new List<string>();
foreach (var documentChange in result.DocumentChanges)
{
edit.Replace(change.Span.ToSpan(), change.NewText);
}
// Get the document and open it. Since we're modifying the text buffer we don't care about linked documents.
var documentBuffer = workspace.GetTestDocument(documentChange.Key).GetTextBuffer();

using var edit = documentBuffer.CreateEdit();
foreach (var change in documentChange.Value)
{
edit.Replace(change.Span.ToSpan(), change.NewText);
}

edit.Apply();
edit.Apply();

Assert.Equal(expectedText, textBuffer.CurrentSnapshot.GetText());
actualDocumentTexts.Add(documentBuffer.CurrentSnapshot.GetText());
}

Assert.Equal(expectedTexts.Length, actualDocumentTexts.Count);
foreach (var expectedText in expectedTexts)
{
Assert.True(actualDocumentTexts.Contains(expectedText));
}
}
}
}
160 changes: 160 additions & 0 deletions src/EditorFeatures/CSharpTest/Intents/RenameIntentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// 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.

using System.Linq;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Features.Intents;
using Microsoft.CodeAnalysis.Test.Utilities;
using Microsoft.CodeAnalysis.Text;
using Xunit;

namespace Microsoft.CodeAnalysis.Editor.CSharp.UnitTests.Intents;

[UseExportProvider]
public class RenameIntentTests : IntentTestsBase
{
[Fact]
public async Task TestRenameIntentAsync()
{
var initialText =
@"class C
{
void M()
{
var thing = 1;
{|typed:something|}.ToString();
}
}";
var expectedText =
@"class C
{
void M()
{
var something = 1;
something.ToString();
}
}";

await VerifyExpectedRenameAsync(initialText, expectedText, "thing", "something").ConfigureAwait(false);
}

[Fact]
public async Task TestRenameIntentAsync_Insert()
{
var initialText =
@"class C
{
void M()
{
var thing = 1;
{|typed:some|}thing.ToString();
}
}";
var expectedText =
@"class C
{
void M()
{
var something = 1;
something.ToString();
}
}";

await VerifyExpectedRenameAsync(initialText, expectedText, string.Empty, "something").ConfigureAwait(false);
}

[Fact]
public async Task TestRenameIntentAsync_Delete()
{
var initialText =
@"class C
{
void M()
{
var something = 1;
{|typed:|}thing.ToString();
}
}";
var expectedText =
@"class C
{
void M()
{
var thing = 1;
thing.ToString();
}
}";

await VerifyExpectedRenameAsync(initialText, expectedText, "some", "thing").ConfigureAwait(false);
}

[Fact]
public async Task TestRenameIntentAsync_MultipleFiles()
{
var initialText =
@"namespace M
{
public class C
{
public static string {|typed:BetterString|} = string.Empty;

void M()
{
var m = SomeString;
}
}
}";
var additionalDocuments = new string[]
{
@"namespace M
{
public class D
{
void M()
{
var m = C.SomeString;
}
}
}"
};

var expectedTexts = new string[]
{
@"namespace M
{
public class C
{
public static string BetterString = string.Empty;

void M()
{
var m = BetterString;
}
}
}",
@"namespace M
{
public class D
{
void M()
{
var m = C.BetterString;
}
}
}"
};

await VerifyExpectedRenameAsync(initialText, additionalDocuments, expectedTexts, "SomeString", "BetterString").ConfigureAwait(false);
}

private static Task VerifyExpectedRenameAsync(string initialText, string expectedText, string priorText, string newName)
{
return VerifyExpectedTextAsync(WellKnownIntents.Rename, initialText, expectedText, intentData: $"{{ \"newName\": \"{newName}\" }}", priorText: priorText);
}

private static Task VerifyExpectedRenameAsync(string initialText, string[] additionalText, string[] expectedTexts, string priorText, string newName)
{
return VerifyExpectedTextAsync(WellKnownIntents.Rename, initialText, additionalText, expectedTexts, intentData: $"{{ \"newName\": \"{newName}\" }}", priorText: priorText);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,31 @@ internal readonly struct IntentSource

/// <summary>
/// The text changes that should be applied to the <see cref="IntentRequestContext.CurrentSnapshotSpan"/>
/// TODO - Remove once intellicode switches over to reading <see cref="DocumentChanges"/> instead.
/// </summary>
[Obsolete("Use DocumentChanges instead")]
Copy link
Member Author

Choose a reason for hiding this comment

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

rename requires us to support multiple document changes. instead of outright removing this, obsoleting until intellicode can react to avoid dual insertion

public readonly ImmutableArray<TextChange> TextChanges { get; }

/// <summary>
/// The text changes that should be applied to each document.
/// </summary>
public readonly ImmutableDictionary<DocumentId, ImmutableArray<TextChange>> DocumentChanges;

/// <summary>
/// Contains metadata that can be used to identify the kind of sub-action these edits
/// apply to for the requested intent. Used for telemetry purposes only.
/// For example, the code action type name like FieldDelegatingCodeAction.
/// </summary>
public readonly string ActionName { get; }

public IntentSource(string title, ImmutableArray<TextChange> textChanges, string actionName)
public IntentSource(string title, ImmutableArray<TextChange> textChanges, string actionName, ImmutableDictionary<DocumentId, ImmutableArray<TextChange>> documentChanges)
{
#pragma warning disable CS0618 // Type or member is obsolete
TextChanges = textChanges;
#pragma warning restore CS0618 // Type or member is obsolete
Title = title ?? throw new ArgumentNullException(nameof(title));
ActionName = actionName ?? throw new ArgumentNullException(nameof(actionName));
DocumentChanges = documentChanges;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Microsoft.CodeAnalysis.Features.Intents;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.LanguageServer;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
Expand Down Expand Up @@ -93,22 +94,41 @@ public async Task<ImmutableArray<IntentSource>> ComputeIntentsAsync(IntentReques
CancellationToken cancellationToken)
{
var newSolution = processorResult.Solution;

// Merge linked file changes so all linked files have the same text changes.
newSolution = await newSolution.WithMergedLinkedFileChangesAsync(originalDocument.Project.Solution, cancellationToken: cancellationToken).ConfigureAwait(false);

// For now we only support changes to the current document. Everything else is dropped.
var changedDocument = newSolution.GetRequiredDocument(currentDocument.Id);
using var _ = PooledDictionary<DocumentId, ImmutableArray<TextChange>>.GetInstance(out var results);
foreach (var changedDocumentId in processorResult.ChangedDocuments)
{
// Calculate the text changes by comparing the solution with intent applied to the current solution (not to be confused with the original solution, the one prior to intent detection).
var docChanges = await GetTextChangesForDocumentAsync(newSolution, currentDocument.Project.Solution, changedDocumentId, cancellationToken).ConfigureAwait(false);
if (docChanges != null)
{
results[changedDocumentId] = docChanges.Value;
}
}

return new IntentSource(processorResult.Title, results[originalDocument.Id], processorResult.ActionName, results.ToImmutableDictionary());
}

private static async Task<ImmutableArray<TextChange>?> GetTextChangesForDocumentAsync(
Solution changedSolution,
Solution currentSolution,
DocumentId changedDocumentId,
CancellationToken cancellationToken)
{
var changedDocument = changedSolution.GetRequiredDocument(changedDocumentId);
var currentDocument = currentSolution.GetRequiredDocument(changedDocumentId);

var textDiffService = newSolution.Workspace.Services.GetRequiredService<IDocumentTextDifferencingService>();
var textDiffService = changedSolution.Workspace.Services.GetRequiredService<IDocumentTextDifferencingService>();
// Compute changes against the current version of the document.
var textDiffs = await textDiffService.GetTextChangesAsync(currentDocument, changedDocument, cancellationToken).ConfigureAwait(false);
if (textDiffs.IsEmpty)
{
return null;
}

return new IntentSource(processorResult.Title, textDiffs, processorResult.ActionName);
return textDiffs;
}
}
}
Loading