Skip to content

Commit

Permalink
Add a code action to promote a using directive (#11241)
Browse files Browse the repository at this point in the history
Fixes #6155

Me: I really like the cohosting code action tests, it must be really
easy to add a new code action now!
Me: Oh really? Well, try it and find out, I dare you!!
Me: <this PR>

(Also me: That wasn't as much fun as I expected. I have thoughts.)
  • Loading branch information
davidwengier authored Nov 28, 2024
2 parents e4d1b9e + 9850344 commit 5f32f26
Show file tree
Hide file tree
Showing 24 changed files with 455 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ public static void AddCodeActionsServices(this IServiceCollection services)
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>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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("usingStart")]
public required int UsingStart { get; init; }

[JsonPropertyName("usingEnd")]
public required int UsingEnd { get; init; }

[JsonPropertyName("removeStart")]
public required int RemoveStart { get; init; }

[JsonPropertyName("removeEnd")]
public required int RemoveEnd { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 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>();
}

var directive = owner.FirstAncestorOrSelf<RazorDirectiveSyntax>();
if (directive is null || !directive.IsUsingDirective(out _))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var importFileName = GetImportsFileName(context.DocumentSnapshot.FileKind);

var line = context.CodeDocument.Source.Text.Lines.GetLineFromPosition(context.StartAbsoluteIndex);
var data = new PromoteToUsingCodeActionParams
{
UsingStart = directive.SpanStart,
UsingEnd = directive.Span.End,
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]);
}

public static string GetImportsFileName(string fileKind)
{
return FileKinds.IsLegacy(fileKind)
? MvcImportProjectFeature.ImportsFileName
: ComponentMetadata.ImportsFileName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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.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 sourceText = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);

var importsFileName = PromoteUsingCodeActionProvider.GetImportsFileName(documentContext.FileKind);

var file = FilePathNormalizer.Normalize(documentContext.Uri.GetAbsoluteOrUNCPath());
var folder = Path.GetDirectoryName(file).AssumeNotNull();
var importsFile = Path.GetFullPath(Path.Combine(folder, "..", 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 = sourceText.GetSubTextString(TextSpan.FromBounds(actionParams.UsingStart, actionParams.UsingEnd));
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[^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 = sourceText.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()
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public static class CodeActions

public const string AddUsing = "AddUsing";

public const string PromoteUsingDirective = "PromoteUsingDirective";

public const string CodeActionFromVSCode = "CodeActionFromVSCode";

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 5f32f26

Please sign in to comment.