Skip to content

Commit

Permalink
Code action to offer to wrap Html attributes (#11422)
Browse files Browse the repository at this point in the history
Fixes #4296
  • Loading branch information
davidwengier authored Feb 4, 2025
2 parents 9e9da3e + 1bc2dc0 commit b16f63a
Show file tree
Hide file tree
Showing 22 changed files with 393 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ public static void AddCodeActionsServices(this IServiceCollection services)
services.AddSingleton<IRazorCodeActionResolver, GenerateMethodCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, PromoteUsingCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, PromoteUsingCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, WrapAttributesCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, WrapAttributesCodeActionResolver>();

// Html Code actions
services.AddSingleton<IHtmlCodeActionProvider, HtmlCodeActionProvider>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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 WrapAttributesCodeActionParams
{
[JsonPropertyName("indentSize")]
public int IndentSize { get; init; }

[JsonPropertyName("newLinePositions")]
public required int[] NewLinePositions { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ internal static class RazorCodeActionFactory
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");
private readonly static Guid s_wrapAttributesTelemetryId = new("1df50ba6-4ed1-40d8-8fe2-1c4c1b08e8b5");

public static RazorVSInternalCodeAction CreateWrapAttributes(RazorCodeActionResolutionParams resolutionParams)
=> new RazorVSInternalCodeAction
{
Title = SR.Wrap_attributes,
Data = JsonSerializer.SerializeToElement(resolutionParams),
TelemetryId = s_wrapAttributesTelemetryId,
Name = LanguageServerConstants.CodeActions.WrapAttributes,
};

public static RazorVSInternalCodeAction CreatePromoteUsingDirective(string importsFileName, RazorCodeActionResolutionParams resolutionParams)
=> new RazorVSInternalCodeAction
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// 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.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
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 WrapAttributesCodeActionProvider : 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 attributes = FindAttributes(owner);
if (attributes.Count == 0)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var first = true;
var firstAttributeLine = 0;
var indentSize = 0;
var sourceText = context.SourceText;

using var newLinePositions = new PooledArrayBuilder<int>(attributes.Count);
foreach (var attribute in attributes)
{
var linePositionSpan = attribute.GetLinePositionSpan(context.CodeDocument.Source);

if (first)
{
firstAttributeLine = linePositionSpan.Start.Line;
sourceText.TryGetFirstNonWhitespaceOffset(attribute.Span, out var indentSizeOffset);
indentSize = linePositionSpan.Start.Character + indentSizeOffset;
first = false;
}
else
{
if (linePositionSpan.Start.Line != firstAttributeLine)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (!sourceText.TryGetFirstNonWhitespaceOffset(attribute.Span, out var startOffset))
{
continue;
}

newLinePositions.Add(attribute.SpanStart + startOffset);
}
}

if (newLinePositions.Count == 0)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var data = new WrapAttributesCodeActionParams
{
IndentSize = indentSize,
NewLinePositions = newLinePositions.ToArray()
};

var resolutionParams = new RazorCodeActionResolutionParams()
{
TextDocument = context.Request.TextDocument,
Action = LanguageServerConstants.CodeActions.WrapAttributes,
Language = RazorLanguageKind.Razor,
DelegatedDocumentUri = context.DelegatedDocumentUri,
Data = data
};

var action = RazorCodeActionFactory.CreateWrapAttributes(resolutionParams);

return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([action]);
}

private AspNetCore.Razor.Language.Syntax.SyntaxList<RazorSyntaxNode> FindAttributes(AspNetCore.Razor.Language.Syntax.SyntaxNode owner)
{
foreach (var node in owner.AncestorsAndSelf())
{
if (node is MarkupStartTagSyntax startTag)
{
return startTag.Attributes;
}
else if (node is MarkupTagHelperStartTagSyntax tagHelperElement)
{
return tagHelperElement.Attributes;
}
else if (node is MarkupElementSyntax or MarkupTagHelperElementSyntax)
{
// If we get as high as the element, we're done looking
break;
}
}

return [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.CodeAnalysis.Razor.CodeActions;

internal class WrapAttributesCodeActionResolver : IRazorCodeActionResolver
{
public string Action => LanguageServerConstants.CodeActions.WrapAttributes;

public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
{
var actionParams = data.Deserialize<WrapAttributesCodeActionParams>();
if (actionParams is null)
{
return null;
}

var indentationString = FormattingUtilities.GetIndentationString(actionParams.IndentSize, options.InsertSpaces, options.TabSize);
var sourceText = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);
using var edits = new PooledArrayBuilder<TextEdit>();

foreach (var position in actionParams.NewLinePositions)
{
var start = sourceText.GetLinePosition(FindPreviousNonWhitespacePosition(sourceText, position) + 1);
var end = sourceText.GetLinePosition(position);
edits.Add(VsLspFactory.CreateTextEdit(start, end, Environment.NewLine + indentationString));
}

var tde = new TextDocumentEdit
{
TextDocument = new OptionalVersionedTextDocumentIdentifier() { Uri = documentContext.Uri },
Edits = edits.ToArray()
};

return new WorkspaceEdit
{
DocumentChanges = new SumType<TextDocumentEdit[], SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[]>([tde])
};
}

private int FindPreviousNonWhitespacePosition(SourceText sourceText, int position)
{
for (var i = position - 1; i >= 0; i--)
{
if (!char.IsWhiteSpace(sourceText[i]))
{
return i;
}
}

return 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public static class CodeActions

public const string CodeActionFromVSCode = "CodeActionFromVSCode";

public const string WrapAttributes = "WrapAttributes";

/// <summary>
/// Remaps without formatting the resolved code action edit
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,7 @@
<data name="Promote_using_directive_to" xml:space="preserve">
<value>Promote using directive to {0}</value>
</data>
<data name="Wrap_attributes" xml:space="preserve">
<value>Wrap attributes</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.

Loading

0 comments on commit b16f63a

Please sign in to comment.