Skip to content

Commit

Permalink
Implement handling of rename intent (#59410)
Browse files Browse the repository at this point in the history
* Implement handling of rename intent

* Use documentId instead of URI

* Move record definition up top

* feedback
  • Loading branch information
dibarbet authored Apr 7, 2022
1 parent fdd40b2 commit e79be97
Show file tree
Hide file tree
Showing 11 changed files with 314 additions and 32 deletions.
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")]
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

0 comments on commit e79be97

Please sign in to comment.