Add a code action to promote a using directive

This commit is contained in:
David Wengier 2024-11-22 11:45:43 +11:00
Родитель fa5b57f1c7
Коммит a5a55ea9e9
24 изменённых файлов: 425 добавлений и 4 удалений

Просмотреть файл

@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Extensions;
internal class MvcImportProjectFeature : RazorProjectEngineFeatureBase, IImportProjectFeature
{
private const string ImportsFileName = "_ViewImports.cshtml";
internal const string ImportsFileName = "_ViewImports.cshtml";
public IReadOnlyList<RazorProjectItem> GetImports(RazorProjectItem projectItem)
{

Просмотреть файл

@ -155,6 +155,8 @@ internal static class IServiceCollectionExtensions
services.AddSingleton<IRazorCodeActionResolver, AddUsingsCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, GenerateMethodCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, GenerateMethodCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, PromoteUsingCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, PromoteUsingCodeActionResolver>();
// Html Code actions
services.AddSingleton<IHtmlCodeActionProvider, HtmlCodeActionProvider>();

Просмотреть файл

@ -0,0 +1,21 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
using System.Text.Json.Serialization;
namespace Microsoft.CodeAnalysis.Razor.CodeActions.Models;
internal sealed class PromoteToUsingCodeActionParams
{
[JsonPropertyName("importsFileName")]
public required string ImportsFileName { get; init; }
[JsonPropertyName("usingDirective")]
public required string UsingDirective { get; init; }
[JsonPropertyName("removeStart")]
public required int RemoveStart { get; init; }
[JsonPropertyName("removeEnd")]
public required int RemoveEnd { get; init; }
}

Просмотреть файл

@ -0,0 +1,72 @@
// 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.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Razor.Extensions;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.Threading;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Text;
namespace Microsoft.CodeAnalysis.Razor.CodeActions;
internal class PromoteUsingCodeActionProvider : IRazorCodeActionProvider
{
public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken)
{
if (context.HasSelection)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
var syntaxTree = context.CodeDocument.GetSyntaxTree();
if (syntaxTree?.Root is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
var owner = syntaxTree.Root.FindNode(TextSpan.FromBounds(context.StartAbsoluteIndex, context.EndAbsoluteIndex));
if (owner is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
if (owner.FirstAncestorOrSelf<RazorDirectiveSyntax>() is not { } directive ||
!directive.IsUsingDirective(out _))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
var importFileName = FileKinds.IsLegacy(context.DocumentSnapshot.FileKind)
? MvcImportProjectFeature.ImportsFileName
: ComponentMetadata.ImportsFileName;
var line = context.CodeDocument.Source.Text.Lines.GetLineFromPosition(context.StartAbsoluteIndex);
var data = new PromoteToUsingCodeActionParams
{
ImportsFileName = importFileName,
UsingDirective = directive.GetContent(),
RemoveStart = line.Start,
RemoveEnd = line.EndIncludingLineBreak
};
var resolutionParams = new RazorCodeActionResolutionParams()
{
TextDocument = context.Request.TextDocument,
Action = LanguageServerConstants.CodeActions.PromoteUsingDirective,
Language = RazorLanguageKind.Razor,
DelegatedDocumentUri = context.DelegatedDocumentUri,
Data = data
};
var action = RazorCodeActionFactory.CreatePromoteUsingDirective(importFileName, resolutionParams);
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([action]);
}
}

Просмотреть файл

@ -0,0 +1,93 @@
// 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.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.AspNetCore.Razor.Utilities;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.LanguageServer.Protocol;
namespace Microsoft.CodeAnalysis.Razor.CodeActions;
internal class PromoteUsingCodeActionResolver(IFileSystem fileSystem) : IRazorCodeActionResolver
{
private readonly IFileSystem _fileSystem = fileSystem;
public string Action => LanguageServerConstants.CodeActions.PromoteUsingDirective;
public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
{
var actionParams = data.Deserialize<PromoteToUsingCodeActionParams>();
if (actionParams is null)
{
return null;
}
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
if (codeDocument.IsUnsupported())
{
return null;
}
var file = FilePathNormalizer.Normalize(documentContext.Uri.GetAbsoluteOrUNCPath());
var folder = Path.GetDirectoryName(file).AssumeNotNull();
var importsFile = Path.GetFullPath(Path.Combine(folder, "..", actionParams.ImportsFileName));
var importFileUri = new UriBuilder
{
Scheme = Uri.UriSchemeFile,
Path = importsFile,
Host = string.Empty,
}.Uri;
using var edits = new PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>();
var textToInsert = actionParams.UsingDirective;
var insertLocation = new LinePosition(0, 0);
if (!_fileSystem.FileExists(importsFile))
{
edits.Add(new CreateFile() { Uri = importFileUri });
}
else
{
var st = SourceText.From(_fileSystem.ReadFile(importsFile));
var lastLine = st.Lines[st.Lines.Count - 1];
insertLocation = new LinePosition(lastLine.LineNumber, 0);
if (lastLine.GetFirstNonWhitespaceOffset() is { } nonWhiteSpaceOffset)
{
// Last line isn't blank, so add a newline, and insert at the end
textToInsert = Environment.NewLine + textToInsert;
insertLocation = new LinePosition(insertLocation.Line, lastLine.SpanIncludingLineBreak.Length);
}
}
edits.Add(new TextDocumentEdit
{
TextDocument = new OptionalVersionedTextDocumentIdentifier() { Uri = importFileUri },
Edits = [VsLspFactory.CreateTextEdit(insertLocation, textToInsert)]
});
var removeRange = codeDocument.Source.Text.GetRange(actionParams.RemoveStart, actionParams.RemoveEnd);
edits.Add(new TextDocumentEdit
{
TextDocument = new OptionalVersionedTextDocumentIdentifier() { Uri = documentContext.Uri },
Edits = [VsLspFactory.CreateTextEdit(removeRange, string.Empty)]
});
return new WorkspaceEdit
{
DocumentChanges = edits.ToArray()
};
}
}

Просмотреть файл

@ -18,6 +18,16 @@ internal static class RazorCodeActionFactory
private readonly static Guid s_createExtractToComponentTelemetryId = new("af67b0a3-f84b-4808-97a7-b53e85b22c64");
private readonly static Guid s_generateMethodTelemetryId = new("c14fa003-c752-45fc-bb29-3a123ae5ecef");
private readonly static Guid s_generateAsyncMethodTelemetryId = new("9058ca47-98e2-4f11-bf7c-a16a444dd939");
private readonly static Guid s_promoteUsingDirectiveTelemetryId = new("751f9012-e37b-444a-9211-b4ebce91d96e");
public static RazorVSInternalCodeAction CreatePromoteUsingDirective(string importsFileName, RazorCodeActionResolutionParams resolutionParams)
=> new RazorVSInternalCodeAction
{
Title = SR.FormatPromote_using_directive_to(importsFileName),
Data = JsonSerializer.SerializeToElement(resolutionParams),
TelemetryId = s_promoteUsingDirectiveTelemetryId,
Name = LanguageServerConstants.CodeActions.PromoteUsingDirective,
};
public static RazorVSInternalCodeAction CreateAddComponentUsing(string @namespace, string? newTagName, RazorCodeActionResolutionParams resolutionParams)
{

Просмотреть файл

@ -45,6 +45,8 @@ internal static class LanguageServerConstants
public const string AddUsing = "AddUsing";
public const string PromoteUsingDirective = "PromoteUsingDirective";
public const string CodeActionFromVSCode = "CodeActionFromVSCode";
/// <summary>

Просмотреть файл

@ -196,4 +196,7 @@
<data name="Statement" xml:space="preserve">
<value>statement</value>
</data>
<data name="Promote_using_directive_to" xml:space="preserve">
<value>Promote using directive to {0}</value>
</data>
</root>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">Proběhl dotaz na řádek {0} mimo rozsah {1} {2}. Dokument nemusí být aktuální.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">příkaz</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">Die Zeile "{0}" außerhalb des {1} Bereichs von "{2}" wurde abgefragt. Das Dokument ist möglicherweise nicht auf dem neuesten Stand.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">Anweisung</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">La línea '{0}' se consultó fuera del {1} rango de '{2}'. Es posible que el documento no esté actualizado.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">instrucción</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">La ligne «{0}» en dehors de la plage{1} de «{2}» a été interrogée. Le document nest peut-être pas à jour.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">déclaration</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">È stata eseguita una query sulla riga '{0}' non compresa nell'intervallo {1} di '{2}'. Il documento potrebbe non essere aggiornato.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">istruzione</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">'{2}' の {1} 範囲外の行 '{0}' がクエリされました。ドキュメントが最新ではない可能性があります。</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">ステートメント</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">'{2}'의 {1} 범위를 벗어나는 줄 '{0}'을(를) 쿼리했습니다. 문서가 최신이 아닐 수 있습니다.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">문</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">Wykonano zapytanie wiersza "{0}" poza zakresem {1} "{2}". Dokument może być nieaktualny.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">instrukcja</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">A linha '{0}' fora do intervalo {1} de '{2}' foi consultada. O documento pode não estar atualizado.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">instrução</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">Запрошена строка "{0}" за пределами диапазона {1} "{2}". Возможно, документ не обновлен.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">инструкция</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">{1} / '{2}' aralığının dışındaki '{0}' satırı sorgulandı. Belge güncel olmayabilir.</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">deyim</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">查询了 "{0}" 的 {1} 范围外的行 "{2}"。文档可能不是最新的。</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">语句</target>

Просмотреть файл

@ -104,6 +104,11 @@
<target state="translated">已查詢 '{2}' 之 {1} 範圍以外的行 '{0}'。文件可能不是最新狀態。</target>
<note />
</trans-unit>
<trans-unit id="Promote_using_directive_to">
<source>Promote using directive to {0}</source>
<target state="new">Promote using directive to {0}</target>
<note />
</trans-unit>
<trans-unit id="Statement">
<source>statement</source>
<target state="translated">陳述式</target>

Просмотреть файл

@ -49,6 +49,9 @@ internal sealed class OOPComponentAccessibilityCodeActionProvider(IFileSystem fi
[Export(typeof(IRazorCodeActionProvider)), Shared]
internal sealed class OOPGenerateMethodCodeActionProvider : GenerateMethodCodeActionProvider;
[Export(typeof(IRazorCodeActionProvider)), Shared]
internal sealed class OOPPromoteUsingDirectiveCodeActionProvider : PromoteUsingCodeActionProvider;
[Export(typeof(ICSharpCodeActionProvider)), Shared]
internal sealed class OOPTypeAccessibilityCodeActionProvider : TypeAccessibilityCodeActionProvider;
@ -89,6 +92,10 @@ internal sealed class OOPGenerateMethodCodeActionResolver(
IFileSystem fileSystem)
: GenerateMethodCodeActionResolver(roslynCodeActionHelpers, documentMappingService, razorFormattingService, fileSystem);
[Export(typeof(IRazorCodeActionResolver)), Shared]
[method: ImportingConstructor]
internal sealed class OOPPromoteUsingDirectiveCodeActionResolver(IFileSystem fileSystem) : PromoteUsingCodeActionResolver(fileSystem);
[Export(typeof(ICSharpCodeActionResolver)), Shared]
[method: ImportingConstructor]
internal sealed class OOPCSharpCodeActionResolver(IRazorFormattingService razorFormattingService) : CSharpCodeActionResolver(razorFormattingService);

Просмотреть файл

@ -7,6 +7,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.AspNetCore.Razor.Test.Common;
@ -15,6 +16,7 @@ using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Remote.Razor;
@ -24,8 +26,8 @@ using Microsoft.VisualStudio.Razor.Settings;
using Roslyn.Test.Utilities;
using Xunit;
using Xunit.Abstractions;
using WorkspacesSR = Microsoft.CodeAnalysis.Razor.Workspaces.Resources.SR;
using LspDiagnostic = Microsoft.VisualStudio.LanguageServer.Protocol.Diagnostic;
using WorkspacesSR = Microsoft.CodeAnalysis.Razor.Workspaces.Resources.SR;
namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;
@ -870,6 +872,150 @@ public class CohostCodeActionsEndpointTest(ITestOutputHelper testOutputHelper) :
""")]);
}
[Fact]
public async Task PromoteUsingDirective()
{
await VerifyCodeActionAsync(
input: """
@using [||]System
<div>
Hello World
</div>
""",
expected: """
<div>
Hello World
</div>
""",
codeActionName: LanguageServerConstants.CodeActions.PromoteUsingDirective,
additionalExpectedFiles: [
(FileUri(@"..\_Imports.razor"), """
@using System
""")]);
}
[Fact]
public async Task PromoteUsingDirective_Mvc()
{
await VerifyCodeActionAsync(
input: """
@using [||]System
<div>
Hello World
</div>
""",
expected: """
<div>
Hello World
</div>
""",
codeActionName: LanguageServerConstants.CodeActions.PromoteUsingDirective,
fileKind: FileKinds.Legacy,
additionalExpectedFiles: [
(FileUri(@"..\_ViewImports.cshtml"), """
@using System
""")]);
}
[Fact]
public async Task PromoteUsingDirective_ExistingImports()
{
await VerifyCodeActionAsync(
input: """
@using [||]System
<div>
Hello World
</div>
""",
additionalFiles: [
(FilePath(@"..\_Imports.razor"), """
@using System.Text
@using Foo.Bar
""")],
expected: """
<div>
Hello World
</div>
""",
codeActionName: LanguageServerConstants.CodeActions.PromoteUsingDirective,
additionalExpectedFiles: [
(FileUri(@"..\_Imports.razor"), """
@using System.Text
@using Foo.Bar
@using System
""")]);
}
[Fact]
public async Task PromoteUsingDirective_ExistingImports_BlankLineAtEnd()
{
await VerifyCodeActionAsync(
input: """
@using [||]System
<div>
Hello World
</div>
""",
additionalFiles: [
(FilePath(@"..\_Imports.razor"), """
@using System.Text
@using Foo.Bar
""")],
expected: """
<div>
Hello World
</div>
""",
codeActionName: LanguageServerConstants.CodeActions.PromoteUsingDirective,
additionalExpectedFiles: [
(FileUri(@"..\_Imports.razor"), """
@using System.Text
@using Foo.Bar
@using System
""")]);
}
[Fact]
public async Task PromoteUsingDirective_ExistingImports_WhitespaceLineAtEnd()
{
await VerifyCodeActionAsync(
input: """
@using [||]System
<div>
Hello World
</div>
""",
additionalFiles: [
(FilePath(@"..\_Imports.razor"), """
@using System.Text
@using Foo.Bar
""")],
expected: """
<div>
Hello World
</div>
""",
codeActionName: LanguageServerConstants.CodeActions.PromoteUsingDirective,
additionalExpectedFiles: [
(FileUri(@"..\_Imports.razor"), """
@using System.Text
@using Foo.Bar
@using System
""")]);
}
private async Task VerifyCodeActionAsync(TestCode input, string? expected, string codeActionName, int childActionIndex = 0, string? fileKind = null, (string filePath, string contents)[]? additionalFiles = null, (Uri fileUri, string contents)[]? additionalExpectedFiles = null)
{
var fileSystem = (RemoteFileSystem)OOPExportProvider.GetExportedValue<IFileSystem>();
@ -905,7 +1051,7 @@ public class CohostCodeActionsEndpointTest(ITestOutputHelper testOutputHelper) :
await VerifyCodeActionResultAsync(document, workspaceEdit, expected, additionalExpectedFiles);
}
private async Task<CodeAction?> VerifyCodeActionRequestAsync(CodeAnalysis.TextDocument document, TestCode input, string codeActionName, int childActionIndex)
private async Task<CodeAction?> VerifyCodeActionRequestAsync(TextDocument document, TestCode input, string codeActionName, int childActionIndex)
{
var requestInvoker = new TestLSPRequestInvoker();
var endpoint = new CohostCodeActionsEndpoint(RemoteServiceInvoker, ClientCapabilitiesService, TestHtmlDocumentSynchronizer.Instance, requestInvoker, NoOpTelemetryReporter.Instance);

Просмотреть файл

@ -272,5 +272,5 @@ public abstract class CohostEndpointTestBase(ITestOutputHelper testOutputHelper)
=> new(FilePath(projectRelativeFileName));
protected static string FilePath(string projectRelativeFileName)
=> Path.Combine(TestProjectData.SomeProjectPath, projectRelativeFileName);
=> Path.GetFullPath(Path.Combine(TestProjectData.SomeProjectPath, projectRelativeFileName));
}