From 9e84b15e2bcc9a537f60f68f9b0eedc564369303 Mon Sep 17 00:00:00 2001 From: marcarro Date: Mon, 8 Jul 2024 15:42:42 -0700 Subject: [PATCH 01/29] Arranging selection logic From merge --- .../Razor/ExtractToComponentCodeActionProviderTest.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs index d884dc9c0b9..b5e312b18c6 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs @@ -178,6 +178,7 @@ public async Task Handle_MultiPointSelection_WithEndAfterElement_ReturnsCurrentE // Arrange var documentPath = "c:/Test.razor"; var contents = """ + @namespace MarketApp.Pages.Product.Home @page "/" @namespace MarketApp.Pages.Product.Home From aa15f7bd33beab8ec0f0b85c236b08da25a4632b Mon Sep 17 00:00:00 2001 From: marcarro Date: Mon, 15 Jul 2024 10:04:42 -0700 Subject: [PATCH 02/29] Completed and corrected selected range extraction functionality FROM MERGE --- ...xtractToComponentCodeActionProviderTest.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs index b5e312b18c6..326d7fd10f5 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs @@ -278,6 +278,127 @@ public async Task Handle_InProperMarkup_ReturnsEmpty() Assert.Empty(commandOrCodeActionContainer); } + [Fact] + public async Task Handle_InProperMarkup_ReturnsNull() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = """ + page "/" + + Home + +
+
+

Div a title

+

Div $$a par

+
+
+

Div b title

+

Div b par

+
+
Hello, world! + + Welcome to your new app. + """; + TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); + + var request = new VSCodeActionParams() + { + TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, + Range = new Range(), + Context = new VSInternalCodeActionContext() + }; + + var location = new SourceLocation(cursorPosition, -1, -1); + var context = CreateRazorCodeActionContext(request, location, documentPath, contents); + + var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Null(commandOrCodeActionContainer); + } + + [Theory] + [InlineData(""" +
+ [|
+

Div a title

+

Div a par

+
|] +
+

Div b title

+

Div b par

+
+
+ """)] + [InlineData(""" +
+
+

Div a title

+ [|

Div a par

|] +
+
+

Div b title

+

Div b par

+
+
+ """)] + [InlineData(""" +
+
+

Div a title

+ [|

Div a par

+
+
+

Div b title

+

Div b par

|] +
+
+ """)] + public async Task Handle_ValidElementSelection_ReturnsNotNull(string markupElementSelection) + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = $$""" + page "/" + + Home + + {{markupElementSelection}} + +

Hello, world!

+ + Welcome to your new app. + """; + + TestFileMarkupParser.GetPositionAndSpans( + contents, out contents, out int cursorPosition, out ImmutableArray spans); + + var request = new VSCodeActionParams() + { + TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, + Range = new Range(), + Context = new VSInternalCodeActionContext() + }; + + var location = new SourceLocation(cursorPosition, -1, -1); + var context = CreateRazorCodeActionContext(request, location, documentPath, contents, supportsFileCreation: true); + + var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.NotNull(commandOrCodeActionContainer); + } + private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, bool supportsFileCreation = true) => CreateRazorCodeActionContext(request, location, filePath, text, relativePath: filePath, supportsFileCreation: supportsFileCreation); From 55588d646602008295e1834f40dbb231b25ad887 Mon Sep 17 00:00:00 2001 From: marcarro Date: Fri, 19 Jul 2024 10:11:41 -0700 Subject: [PATCH 03/29] Include only dependencies included in the selected range --- ...xtractToComponentCodeActionProviderTest.cs | 78 +------------------ 1 file changed, 2 insertions(+), 76 deletions(-) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs index 326d7fd10f5..900cfe66036 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs @@ -154,6 +154,7 @@ public async Task Handle_MultiPointSelection_ReturnsNotEmpty() var location = new SourceLocation(cursorPosition, -1, -1); var context = CreateRazorCodeActionContext(request, location, documentPath, contents); + AddMultiPointSelectionToContext(ref context, selectionSpan); var lineSpan = context.SourceText.GetLinePositionSpan(selectionSpan); request.Range = VsLspFactory.CreateRange(lineSpan); @@ -321,82 +322,7 @@ public async Task Handle_InProperMarkup_ReturnsNull() var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); // Assert - Assert.Null(commandOrCodeActionContainer); - } - - [Theory] - [InlineData(""" -
- [|
-

Div a title

-

Div a par

-
|] -
-

Div b title

-

Div b par

-
-
- """)] - [InlineData(""" -
-
-

Div a title

- [|

Div a par

|] -
-
-

Div b title

-

Div b par

-
-
- """)] - [InlineData(""" -
-
-

Div a title

- [|

Div a par

-
-
-

Div b title

-

Div b par

|] -
-
- """)] - public async Task Handle_ValidElementSelection_ReturnsNotNull(string markupElementSelection) - { - // Arrange - var documentPath = "c:/Test.razor"; - var contents = $$""" - page "/" - - Home - - {{markupElementSelection}} - -

Hello, world!

- - Welcome to your new app. - """; - - TestFileMarkupParser.GetPositionAndSpans( - contents, out contents, out int cursorPosition, out ImmutableArray spans); - - var request = new VSCodeActionParams() - { - TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = new Range(), - Context = new VSInternalCodeActionContext() - }; - - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents, supportsFileCreation: true); - - var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); - - // Act - var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); - - // Assert - Assert.NotNull(commandOrCodeActionContainer); + Assert.Empty(commandOrCodeActionContainer); } private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, bool supportsFileCreation = true) From 57538363cd1d09db2be7d69b69167b1b4c887a93 Mon Sep 17 00:00:00 2001 From: marcarro Date: Thu, 18 Jul 2024 13:05:38 -0700 Subject: [PATCH 04/29] Base component dependency functionality and fixed range selection bug --- .../Models/ExtractToCodeBehindCodeActionParams.cs | 7 +------ .../Razor/ExtractToComponentCodeActionResolver.cs | 4 +++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToCodeBehindCodeActionParams.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToCodeBehindCodeActionParams.cs index cf744ef110b..01704fbc667 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToCodeBehindCodeActionParams.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToCodeBehindCodeActionParams.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; @@ -17,12 +18,6 @@ internal sealed class ExtractToCodeBehindCodeActionParams [JsonPropertyName("extractEnd")] public int ExtractEnd { get; set; } - [JsonPropertyName("removeStart")] - public int RemoveStart { get; set; } - - [JsonPropertyName("removeEnd")] - public int RemoveEnd { get; set; } - [JsonPropertyName("namespace")] public required string Namespace { get; set; } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 4cff9dc9a9d..d3c551f0729 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -109,7 +109,9 @@ internal sealed class ExtractToComponentCodeActionResolver End = new Position(end.Line, end.Character) }; - var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; + + + var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; var documentChanges = new SumType[] From 459605ed373911f02e301c8c1aacc6dac1aeed8d Mon Sep 17 00:00:00 2001 From: marcarro Date: Thu, 1 Aug 2024 14:35:09 -0700 Subject: [PATCH 05/29] Added method to scan identifiers --- .../ExtractToComponentCodeActionProvider.cs | 8 ++- ...xtractToComponentCodeActionProviderTest.cs | 62 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index d6d8967308a..0ada9ca5077 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -1,5 +1,5 @@ // Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. +// Licensed under the MIT license. See License.txt in the project divNode for license information. using System; using System.Collections.Generic; @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.Threading; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; @@ -62,7 +63,7 @@ public Task> ProvideAsync(RazorCodeAct return SpecializedTasks.EmptyImmutableArray(); } - var actionParams = CreateInitialActionParams(context, startElementNode, @namespace); + var actionParams = CreateInitialActionParams(context, startElementNode, @namespace); ProcessSelection(startElementNode, endElementNode, actionParams); @@ -325,5 +326,8 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS } } } + + identifiersInBlock.IntersectWith(identifiersInScope); + actionParams.UsedIdentifiers = identifiersInBlock; } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs index 900cfe66036..3c624963c45 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs @@ -118,6 +118,67 @@ public async Task Handle_SinglePointSelection_ReturnsNotEmpty() Assert.NotEmpty(commandOrCodeActionContainer); } + [Fact] + public async Task Handle_CodeInsideDiv_ScansCorrect() + { + // Arrange + var documentPath = "c:/Test.cs"; + var contents = """ + @page "/" + + Home + + <$$div id="codeInside"> + @for(int idx = 0; idx < 10; idx++) { + string s = someFunc(idx * myField); + } + + +
+
+

Div a title

+

Div a par

+
+
+

Div b title

+

Div b par

+
+
+ + @code { + public int myField = 7; + + public string someFunc(int num) { + return "Hello for number" + num; + } + } + """; + TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); + + var request = new VSCodeActionParams() + { + TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, + Range = new Range(), + Context = new VSInternalCodeActionContext() + }; + + var location = new SourceLocation(cursorPosition, -1, -1); + var context = CreateRazorCodeActionContext(request, location, documentPath, contents); + + var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.NotEmpty(commandOrCodeActionContainer); + var codeAction = Assert.Single(commandOrCodeActionContainer); + var razorCodeActionResolutionParams = ((JsonElement)codeAction.Data!).Deserialize(); + Assert.NotNull(razorCodeActionResolutionParams); + var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize(); + Assert.NotNull(actionParams); + } + [Fact] public async Task Handle_MultiPointSelection_ReturnsNotEmpty() { @@ -179,7 +240,6 @@ public async Task Handle_MultiPointSelection_WithEndAfterElement_ReturnsCurrentE // Arrange var documentPath = "c:/Test.razor"; var contents = """ - @namespace MarketApp.Pages.Product.Home @page "/" @namespace MarketApp.Pages.Product.Home From 1e8168b7f521bcf86fd76a0c2015d086bd5335ae Mon Sep 17 00:00:00 2001 From: marcarro Date: Mon, 5 Aug 2024 09:39:38 -0700 Subject: [PATCH 06/29] Corrected naming and nits --- .../CodeActions/Razor/ExtractToComponentCodeActionProvider.cs | 3 ++- .../CodeActions/Razor/ExtractToComponentCodeActionResolver.cs | 4 +--- .../Razor/ExtractToComponentCodeActionProviderTest.cs | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index 0ada9ca5077..c30d5599cba 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -1,5 +1,5 @@ // Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project divNode for license information. +// Licensed under the MIT license. See License.txt in the project root for license information. using System; using System.Collections.Generic; @@ -327,6 +327,7 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS } } + nodesInBlock.IntersectWith(nodesInScope); identifiersInBlock.IntersectWith(identifiersInScope); actionParams.UsedIdentifiers = identifiersInBlock; } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index d3c551f0729..4cff9dc9a9d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -109,9 +109,7 @@ internal sealed class ExtractToComponentCodeActionResolver End = new Position(end.Line, end.Character) }; - - - var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; + var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; var documentChanges = new SumType[] diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs index 3c624963c45..084eda2ea94 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs @@ -165,7 +165,7 @@ public string someFunc(int num) { var location = new SourceLocation(cursorPosition, -1, -1); var context = CreateRazorCodeActionContext(request, location, documentPath, contents); - var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); // Act var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); @@ -376,7 +376,7 @@ public async Task Handle_InProperMarkup_ReturnsNull() var location = new SourceLocation(cursorPosition, -1, -1); var context = CreateRazorCodeActionContext(request, location, documentPath, contents); - var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); // Act var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); From 419055bf3ef829426e50e8cf038ae2bf6be2b547 Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 7 Aug 2024 14:27:53 -0700 Subject: [PATCH 07/29] Set up basic end to end test for extract component --- .../CodeActionEndToEndTest.NetFx.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs index 992ff410c0e..e86b9e3e225 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs @@ -1544,4 +1544,78 @@ static IEnumerable BuildTagHelpers() } } } + + private class ExtractToComponentResolverDocumentContextFactory : TestDocumentContextFactory + { + private readonly List _tagHelperDescriptors; + + public ExtractToComponentResolverDocumentContextFactory + (string filePath, + RazorCodeDocument codeDocument, + TagHelperDescriptor[]? tagHelpers = null, + int? version = null) + : base(filePath, codeDocument, version) + { + _tagHelperDescriptors = CreateTagHelperDescriptors(); + if (tagHelpers is not null) + { + _tagHelperDescriptors.AddRange(tagHelpers); + } + } + + public override bool TryCreate( + Uri documentUri, + VSProjectContext? projectContext, + bool versioned, + [NotNullWhen(true)] out DocumentContext? context) + { + if (FilePath is null || CodeDocument is null) + { + context = null; + return false; + } + + var projectWorkspaceState = ProjectWorkspaceState.Create(_tagHelperDescriptors.ToImmutableArray()); + var testDocumentSnapshot = TestDocumentSnapshot.Create(FilePath, CodeDocument.GetSourceText().ToString(), CodeAnalysis.VersionStamp.Default, projectWorkspaceState); + testDocumentSnapshot.With(CodeDocument); + + context = CreateDocumentContext(new Uri(FilePath), testDocumentSnapshot); + return true; + } + + private static List CreateTagHelperDescriptors() + { + return BuildTagHelpers().ToList(); + + static IEnumerable BuildTagHelpers() + { + var builder = TagHelperDescriptorBuilder.Create("oncontextmenu", "Microsoft.AspNetCore.Components"); + builder.SetMetadata( + new KeyValuePair(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.MouseEventArgs"), + new KeyValuePair(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind)); + yield return builder.Build(); + + builder = TagHelperDescriptorBuilder.Create("onclick", "Microsoft.AspNetCore.Components"); + builder.SetMetadata( + new KeyValuePair(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.MouseEventArgs"), + new KeyValuePair(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind)); + + yield return builder.Build(); + + builder = TagHelperDescriptorBuilder.Create("oncopy", "Microsoft.AspNetCore.Components"); + builder.SetMetadata( + new KeyValuePair(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.ClipboardEventArgs"), + new KeyValuePair(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind)); + + yield return builder.Build(); + + builder = TagHelperDescriptorBuilder.Create("ref", "Microsoft.AspNetCore.Components"); + builder.SetMetadata( + new KeyValuePair(ComponentMetadata.SpecialKindKey, ComponentMetadata.Ref.TagHelperKind), + new KeyValuePair(ComponentMetadata.Common.DirectiveAttribute, bool.TrueString)); + + yield return builder.Build(); + } + } + } } From 80d7b188ea3ef43abdb3d1aa13b5c35a7428d9a8 Mon Sep 17 00:00:00 2001 From: marcarro Date: Fri, 16 Aug 2024 10:54:36 -0700 Subject: [PATCH 08/29] Refactoring: responsibilities to resolver and not provider --- .../ExtractToComponentCodeActionProvider.cs | 256 +------- .../ExtractToComponentCodeActionResolver.cs | 560 +++++++++++++++++- .../CodeActions/RazorComponentInfoParams.cs | 45 ++ .../Protocol/CustomMessageNames.cs | 1 + .../DefaultLSPRequestInvoker.cs | 12 +- ...rCustomMessageTarget_RazorComponentInfo.cs | 35 ++ .../LanguageClient/RazorLSPConstants.cs | 2 + .../CodeActionEndToEndTest.NetFx.cs | 3 + ...xtractToComponentCodeActionProviderTest.cs | 2 +- 9 files changed, 646 insertions(+), 270 deletions(-) create mode 100644 src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs create mode 100644 src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index c30d5599cba..fd549d0d8ee 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; +using System.CodeDom; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; @@ -16,6 +17,7 @@ using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; +using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; @@ -25,39 +27,17 @@ internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory logger public Task> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) { - if (context is null) - { - return SpecializedTasks.EmptyImmutableArray(); - } - - if (!context.SupportsFileCreation) - { - return SpecializedTasks.EmptyImmutableArray(); - } - - if (!FileKinds.IsComponent(context.CodeDocument.GetFileKind())) + if (!IsValidContext(context)) { return SpecializedTasks.EmptyImmutableArray(); } var syntaxTree = context.CodeDocument.GetSyntaxTree(); - if (syntaxTree?.Root is null) - { - return SpecializedTasks.EmptyImmutableArray(); - } - - // Make sure the selection starts on an element tag - var (startElementNode, endElementNode) = GetStartAndEndElements(context, syntaxTree, _logger); - if (startElementNode is null) + if (!IsSelectionValid(context, syntaxTree)) { return SpecializedTasks.EmptyImmutableArray(); } - if (endElementNode is null) - { - endElementNode = startElementNode; - } - if (!TryGetNamespace(context.CodeDocument, out var @namespace)) { return SpecializedTasks.EmptyImmutableArray(); @@ -92,24 +72,19 @@ public Task> ProvideAsync(RazorCodeAct return Task.FromResult>([codeAction]); } - private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAndEndElements(RazorCodeActionContext context, RazorSyntaxTree syntaxTree, ILogger logger) + private static bool IsValidContext(RazorCodeActionContext context) { - var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); - if (owner is null) - { - logger.LogWarning($"Owner should never be null."); - return (null, null); - } - - var startElementNode = owner.FirstAncestorOrSelf(); - if (startElementNode is null || IsInsideProperHtmlContent(context, startElementNode)) - { - return (null, null); - } - - var endElementNode = GetEndElementNode(context, syntaxTree); + return context is not null && + context.SupportsFileCreation && + FileKinds.IsComponent(context.CodeDocument.GetFileKind()) && + context.CodeDocument.GetSyntaxTree()?.Root is not null; + } - return (startElementNode, endElementNode); + private static bool IsSelectionValid(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) + { + var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); + var startElementNode = owner?.FirstAncestorOrSelf(); + return startElementNode is not null && !IsInsideProperHtmlContent(context, startElementNode) && !HasDiagnosticErrors(startElementNode); } private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, MarkupElementSyntax startElementNode) @@ -124,91 +99,10 @@ private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, Ma context.Location.AbsoluteIndex < startElementNode.EndTag.SpanStart; } - private static MarkupElementSyntax? GetEndElementNode(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) + private static bool HasDiagnosticErrors(MarkupElementSyntax markupElement) { - var selectionStart = context.Request.Range.Start; - var selectionEnd = context.Request.Range.End; - if (selectionStart == selectionEnd) - { - return null; - } - - var endAbsoluteIndex = context.SourceText.GetRequiredAbsoluteIndex(selectionEnd); - var endOwner = syntaxTree.Root.FindInnermostNode(endAbsoluteIndex, true); - if (endOwner is null) - { - return null; - } - - // Correct selection to include the current node if the selection ends immediately after a closing tag. - if (endOwner is MarkupTextLiteralSyntax && endOwner.ContainsOnlyWhitespace() && endOwner.TryGetPreviousSibling(out var previousSibling)) - { - endOwner = previousSibling; - } - - return endOwner.FirstAncestorOrSelf(); - } - - private static ExtractToComponentCodeActionParams CreateInitialActionParams(RazorCodeActionContext context, MarkupElementSyntax startElementNode, string @namespace) - { - return new ExtractToComponentCodeActionParams - { - Uri = context.Request.TextDocument.Uri, - ExtractStart = startElementNode.Span.Start, - ExtractEnd = startElementNode.Span.End, - Namespace = @namespace, - usingDirectives = [] - }; - } - - /// - /// Processes a multi-point selection to determine the correct range for extraction. - /// - /// The starting element of the selection. - /// The ending element of the selection, if it exists. - /// The parameters for the extraction action, which will be updated. - private static void ProcessSelection(MarkupElementSyntax startElementNode, MarkupElementSyntax? endElementNode, ExtractToComponentCodeActionParams actionParams) - { - // If there's no end element, we can't process a multi-point selection - if (endElementNode is null) - { - return; - } - - var startNodeContainsEndNode = endElementNode.Ancestors().Any(node => node == startElementNode); - - // If the start element is an ancestor, keep the original end; otherwise, use the end of the end element - if (startNodeContainsEndNode) - { - actionParams.ExtractEnd = startElementNode.Span.End; - return; - } - - // If the start element is not an ancestor of the end element, we need to find a common parent - // This conditional handles cases where the user's selection spans across different levels of the DOM. - // For example: - //
- // {|result: - // {|selection:

Some text

- //
- // - //

More text

- //
- // - // |}|} - //
- // In this case, we need to find the smallest set of complete elements that covers the entire selection. - - // Find the closest containing sibling pair that encompasses both the start and end elements - var (extractStart, extractEnd) = FindContainingSiblingPair(startElementNode, endElementNode); - - // If we found a valid containing pair, update the extraction range - if (extractStart is not null && extractEnd is not null) - { - actionParams.ExtractStart = extractStart.Span.Start; - actionParams.ExtractEnd = extractEnd.Span.End; - } - // Note: If we don't find a valid pair, we keep the original extraction range + var diagnostics = markupElement.GetDiagnostics(); + return diagnostics.Any(d => d.Severity == RazorDiagnosticSeverity.Error); } private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen(returnValue: true)] out string? @namespace) @@ -217,118 +111,4 @@ private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen // and causing compiler errors. Avoid offering this refactoring if we can't accurately get a // good namespace to extract to => codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out @namespace); - - private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) - { - // Find the lowest common ancestor of both nodes - var nearestCommonAncestor = FindNearestCommonAncestor(startNode, endNode); - if (nearestCommonAncestor is null) - { - return (null, null); - } - - SyntaxNode? startContainingNode = null; - SyntaxNode? endContainingNode = null; - - // Pre-calculate the spans for comparison - var startSpan = startNode.Span; - var endSpan = endNode.Span; - - foreach (var child in nearestCommonAncestor.ChildNodes().Where(static node => node.Kind == SyntaxKind.MarkupElement)) - { - var childSpan = child.Span; - - if (startContainingNode is null && childSpan.Contains(startSpan)) - { - startContainingNode = child; - if (endContainingNode is not null) - break; // Exit if we've found both - } - - if (childSpan.Contains(endSpan)) - { - endContainingNode = child; - if (startContainingNode is not null) - break; // Exit if we've found both - } - } - - return (startContainingNode, endContainingNode); - } - - private static SyntaxNode? FindNearestCommonAncestor(SyntaxNode node1, SyntaxNode node2) - { - var current = node1; - - while (current is MarkupElementSyntax or - MarkupTagHelperAttributeSyntax or - MarkupBlockSyntax && - current is not null) - { - if (current.Span.Contains(node2.Span)) - { - return current; - } - - current = current.Parent; - } - - return null; - } - - private static void AddUsingDirectivesInRange(SyntaxNode root, IEnumerable usingsInSourceRazor, int extractStart, int extractEnd, ExtractToComponentCodeActionParams actionParams) - { - var components = new HashSet(); - var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); - - foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) - { - if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) - { - AddUsingFromTagHelperInfo(tagHelperInfo, components, usingsInSourceRazor, actionParams); - } - } - } - - private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet components, IEnumerable usingsInSourceRazor, ExtractToComponentCodeActionParams actionParams) - { - foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) - { - if (descriptor is null) - { - continue; - } - - var typeNamespace = descriptor.GetTypeNamespace(); - - // Since the using directive at the top of the file may be relative and not absolute, - // we need to generate all possible partial namespaces from `typeNamespace`. - - // Potentially, the alternative could be to ask if the using namespace at the top is a substring of `typeNamespace`. - // The only potential edge case is if there are very similar namespaces where one - // is a substring of the other, but they're actually different (e.g., "My.App" and "My.Apple"). - - // Generate all possible partial namespaces from `typeNamespace`, from least to most specific - // (assuming that the user writes absolute `using` namespaces most of the time) - - // This is a bit inefficient because at most 'k' string operations are performed (k = parts in the namespace), - // for each potential using directive. - - var parts = typeNamespace.Split('.'); - for (var i = 0; i < parts.Length; i++) - { - var partialNamespace = string.Join(".", parts.Skip(i)); - - if (components.Add(partialNamespace) && usingsInSourceRazor.Contains(partialNamespace)) - { - actionParams.usingDirectives.Add($"@using {partialNamespace}"); - break; - } - } - } - - nodesInBlock.IntersectWith(nodesInScope); - identifiersInBlock.IntersectWith(identifiersInScope); - actionParams.UsedIdentifiers = identifiersInBlock; - } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 4cff9dc9a9d..54a0a2d5bd5 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -23,18 +23,39 @@ using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.VisualStudio.LanguageServer.Protocol; using Newtonsoft.Json.Linq; +using static System.Net.Mime.MediaTypeNames; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; +using Microsoft.CodeAnalysis.Differencing; +using Microsoft.AspNetCore.Razor.Language.Syntax; +using ICSharpCode.Decompiler.Semantics; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; +using System.Security.AccessControl; +using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; +using static Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor.ExtractToComponentCodeActionProvider; +using Microsoft.VisualStudio.Text; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; -namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; -internal sealed class ExtractToComponentCodeActionResolver - ( +internal sealed class ExtractToComponentCodeActionResolver( IDocumentContextFactory documentContextFactory, - LanguageServerFeatureOptions languageServerFeatureOptions) : IRazorCodeActionResolver + RazorLSPOptionsMonitor razorLSPOptionsMonitor, + LanguageServerFeatureOptions languageServerFeatureOptions, + IClientConnection clientConnection, + IRazorFormattingService razorFormattingService, + IDocumentVersionCache documentVersionCache) : IRazorCodeActionResolver { + private static readonly Workspace s_workspace = new AdhocWorkspace(); private readonly IDocumentContextFactory _documentContextFactory = documentContextFactory; + private readonly RazorLSPOptionsMonitor _razorLSPOptionsMonitor = razorLSPOptionsMonitor; private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions; + private readonly IClientConnection _clientConnection = clientConnection; + private readonly IRazorFormattingService _razorFormattingService = razorFormattingService; + private readonly IDocumentVersionCache _documentVersionCache = documentVersionCache; public string Action => LanguageServerConstants.CodeActions.ExtractToNewComponentAction; @@ -62,6 +83,21 @@ internal sealed class ExtractToComponentCodeActionResolver return null; } + var selectionAnalysis = TryAnalyzeSelection(componentDocument, actionParams); + + if (!selectionAnalysis.Success) + { + return null; + } + + var start = componentDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractStart); + var end = componentDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractEnd); + var removeRange = new Range + { + Start = new Position(start.Line, start.Character), + End = new Position(end.Line, end.Character) + }; + if (!FileKinds.IsComponent(componentDocument.GetFileKind())) { return null; @@ -84,31 +120,14 @@ internal sealed class ExtractToComponentCodeActionResolver Host = string.Empty, }.Uri; - var text = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); - if (text is null) - { - return null; - } - var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentContent = string.Empty; + var newComponentContent = await GenerateNewComponentAsync(selectionAnalysis, componentDocument, actionParams.Uri, documentContext, removeRange, cancellationToken).ConfigureAwait(false); - newComponentContent += string.Join(Environment.NewLine, actionParams.usingDirectives); - if (actionParams.usingDirectives.Count > 0) + if (newComponentContent is null) { - newComponentContent += Environment.NewLine + Environment.NewLine; // Ensure there's a newline after the dependencies if any exist. + return null; } - newComponentContent += text.GetSubTextString(new CodeAnalysis.Text.TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart)).Trim(); - - var start = componentDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractStart); - var end = componentDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractEnd); - var removeRange = new Range - { - Start = new Position(start.Line, start.Character), - End = new Position(end.Line, end.Character) - }; - var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; @@ -118,32 +137,513 @@ internal sealed class ExtractToComponentCodeActionResolver new TextDocumentEdit { TextDocument = componentDocumentIdentifier, - Edits = new[] - { + Edits = + [ new TextEdit { NewText = $"<{componentName} />", Range = removeRange, } - }, + ], }, new TextDocumentEdit { TextDocument = newComponentDocumentIdentifier, - Edits = new[] - { + Edits = + [ new TextEdit { NewText = newComponentContent, Range = new Range { Start = new Position(0, 0), End = new Position(0, 0) }, } - }, + ], } }; + //if (!_documentContextFactory.TryCreateForOpenDocument(newComponentUri, out var versionedDocumentContext)) + //{ + // throw new InvalidOperationException("Failed to create a versioned document context for the new component"); + //} + + //var formattingOptions = new VisualStudio.LanguageServer.Protocol.FormattingOptions() + //{ + // TabSize = _razorLSPOptionsMonitor.CurrentValue.TabSize, + // InsertSpaces = _razorLSPOptionsMonitor.CurrentValue.InsertSpaces, + // OtherOptions = new Dictionary + // { + // { "trimTrailingWhitespace", true }, + // { "insertFinalNewline", true }, + // { "trimFinalNewlines", true }, + // }, + //}; + + //TextEdit[]? formattedEdits; + //try + //{ + // formattedEdits = await _razorFormattingService.FormatAsync( + // documentContext, + // range: removeRange, + // formattingOptions, + // cancellationToken: default).ConfigureAwait(false); + //} + //catch (Exception ex) + //{ + // throw new InvalidOperationException("Failed to format the new component", ex); + //} + return new WorkspaceEdit { DocumentChanges = documentChanges, }; } + + internal sealed record SelectionAnalysisResult + { + public required bool Success; + public required int ExtractStart; + public required int ExtractEnd; + public required HashSet ComponentDependencies; + } + + private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) + { + var result = new SelectionAnalysisResult + { + Success = false, + ExtractStart = 0, + ExtractEnd = 0, + ComponentDependencies = [], + }; + + var (startElementNode, endElementNode) = GetStartAndEndElements(codeDocument, actionParams); + if (startElementNode is null) + { + return result; + } + + endElementNode ??= startElementNode; + + var (success, extractStart, extractEnd) = TryProcessMultiPointSelection(startElementNode, endElementNode, codeDocument, actionParams); + + var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; + var dependencies = AddComponentDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); + + result.Success = success; + result.ExtractStart = extractStart; + result.ExtractEnd = extractEnd; + result.ComponentDependencies = dependencies; + + return result; + } + + private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAndEndElements(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) + { + var syntaxTree = codeDocument.GetSyntaxTree(); + if (syntaxTree is null) + { + return (null, null); + } + + var owner = syntaxTree.Root.FindInnermostNode(actionParams.AbsoluteIndex, includeWhitespace: true); + if (owner is null) + { + return (null, null); + } + + var startElementNode = owner.FirstAncestorOrSelf(); + if (startElementNode is null || IsInsideProperHtmlContent(actionParams.AbsoluteIndex, startElementNode)) + { + return (null, null); + } + + var sourceText = codeDocument.GetSourceText(); + if (sourceText is null) + { + return (null, null); + } + + var endElementNode = TryGetEndElementNode(actionParams.SelectStart, actionParams.SelectEnd, syntaxTree, sourceText); + + return (startElementNode, endElementNode); + } + + private static bool IsInsideProperHtmlContent(int absoluteIndex, MarkupElementSyntax startElementNode) + { + // If the provider executes before the user/completion inserts an end tag, the below return fails + if (startElementNode.EndTag.IsMissing) + { + return true; + } + + return absoluteIndex > startElementNode.StartTag.Span.End && + absoluteIndex < startElementNode.EndTag.SpanStart; + } + + private static MarkupElementSyntax? TryGetEndElementNode(Position selectionStart, Position selectionEnd, RazorSyntaxTree syntaxTree, SourceText sourceText) + { + if (selectionStart == selectionEnd) + { + return null; + } + + var endLocation = GetEndLocation(selectionEnd, sourceText); + if (!endLocation.HasValue) + { + return null; + } + + var endOwner = syntaxTree.Root.FindInnermostNode(endLocation.Value.AbsoluteIndex, true); + + if (endOwner is null) + { + return null; + } + + // Correct selection to include the current node if the selection ends at the "edge" (i.e. immediately after the ">") of a tag. + if (string.IsNullOrWhiteSpace(endOwner.ToFullString()) && endOwner.TryGetPreviousSibling(out var previousSibling)) + { + endOwner = previousSibling; + } + + return endOwner?.FirstAncestorOrSelf(); + } + + private static SourceLocation? GetEndLocation(Position selectionEnd, SourceText sourceText) + { + if (!selectionEnd.TryGetSourceLocation(sourceText, logger: default, out var location)) + { + return null; + } + + return location; + } + + /// + /// Processes a multi-point selection to determine the correct range for extraction. + /// + /// The starting element of the selection. + /// The ending element of the selection, if it exists. + /// The code document containing the selection. + /// The parameters for the extraction action, which will be updated. + /// one more line for output + /// A tuple containing a boolean indicating success, the start of the extraction range, and the end of the extraction range. + private static (bool success, int extractStart, int extractEnd) TryProcessMultiPointSelection(MarkupElementSyntax startElementNode, MarkupElementSyntax endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) + { + var extractStart = startElementNode.Span.Start; + var extractEnd = endElementNode.Span.End; + + // Check if it's a multi-point selection + if (actionParams.SelectStart == actionParams.SelectEnd) + { + return (true, extractStart, extractEnd); + } + + // Check if the start element is an ancestor of the end element or vice versa + var selectionStartHasParentElement = endElementNode.Ancestors().Any(node => node == startElementNode); + var selectionEndHasParentElement = startElementNode.Ancestors().Any(node => node == endElementNode); + + // If the start element is an ancestor of the end element (or vice versa), update the extraction range + extractStart = selectionEndHasParentElement ? endElementNode.Span.Start : extractStart; + extractEnd = selectionStartHasParentElement ? startElementNode.Span.End : extractEnd; + + // If the start element is not an ancestor of the end element (or vice versa), we need to find a common parent + // This conditional handles cases where the user's selection spans across different levels of the DOM. + // For example: + //
+ // + // Selected text starts here

Some text

+ //
+ // + //

More text

+ //
+ // Selected text ends here + //
+ // In this case, we need to find the smallest set of complete elements that covers the entire selection. + if (startElementNode != endElementNode && !(selectionStartHasParentElement || selectionEndHasParentElement)) + { + // Find the closest containing sibling pair that encompasses both the start and end elements + var (selectStart, selectEnd) = FindContainingSiblingPair(startElementNode, endElementNode); + + // If we found a valid containing pair, update the extraction range + if (selectStart is not null && selectEnd is not null) + { + extractStart = selectStart.Span.Start; + extractEnd = selectEnd.Span.End; + + return (true, extractStart, extractEnd); + } + // Note: If we don't find a valid pair, we keep the original extraction range + } + + if (startElementNode != endElementNode) + { + return (true, extractStart, extractEnd); // Will only trigger when the end of the selection does not include a code block. + } + + var endLocation = GetEndLocation(actionParams.SelectEnd, codeDocument.GetSourceText()); + if (!endLocation.HasValue) + { + return (false, extractStart, extractEnd); + } + + var endOwner = codeDocument.GetSyntaxTree().Root.FindInnermostNode(endLocation.Value.AbsoluteIndex, true); + var endCodeBlock = endOwner?.FirstAncestorOrSelf(); + if (endOwner is not null && endOwner.TryGetPreviousSibling(out var previousSibling)) + { + endCodeBlock = previousSibling.FirstAncestorOrSelf(); + } + + if (endCodeBlock is null) + { + // One of the cases where this triggers is when a single element is multi-pointedly selected + return (true, extractStart, extractEnd); + } + + var (withCodeBlockStart, withCodeBlockEnd) = FindContainingSiblingPair(startElementNode, endCodeBlock); + + // If selection ends on code block, set the extract end to the end of the code block. + extractStart = withCodeBlockStart?.Span.Start ?? extractStart; + extractEnd = withCodeBlockEnd?.Span.End ?? extractEnd; + + return (true, extractStart, extractEnd); + } + + private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) + { + // Find the lowest common ancestor of both nodes + var nearestCommonAncestor = FindNearestCommonAncestor(startNode, endNode); + if (nearestCommonAncestor is null) + { + return (null, null); + } + + SyntaxNode? startContainingNode = null; + SyntaxNode? endContainingNode = null; + + // Pre-calculate the spans for comparison + var startSpan = startNode.Span; + var endSpan = endNode.Span; + + var endIsCodeBlock = endNode is CSharpCodeBlockSyntax; + + foreach (var child in nearestCommonAncestor.ChildNodes().Where(node => IsValidNode(node, endIsCodeBlock))) + { + var childSpan = child.Span; + + if (startContainingNode is null && childSpan.Contains(startSpan)) + { + startContainingNode = child; + if (endContainingNode is not null) + break; // Exit if we've found both + } + + if (childSpan.Contains(endSpan)) + { + endContainingNode = child; + if (startContainingNode is not null) + break; // Exit if we've found both + } + } + + return (startContainingNode, endContainingNode); + } + + private static SyntaxNode? FindNearestCommonAncestor(SyntaxNode node1, SyntaxNode node2) + { + var current = node1; + var secondNodeIsCodeBlock = node2 is CSharpCodeBlockSyntax; + + while (current is not null) + { + if (ShouldCheckNode(current, secondNodeIsCodeBlock) && current.Span.Contains(node2.Span)) + { + return current; + } + + current = current.Parent; + } + + return null; + } + + // Whenever the multi point selection includes a code block at the end, the logic for finding the nearest common ancestor and containing sibling pair + // should accept nodes of type MarkupBlockSyntax and CSharpCodeBlock each, respectively. ShouldCheckNode() and IsValidNode() handle these cases. + private static bool ShouldCheckNode(SyntaxNode node, bool isCodeBlock) + { + if (isCodeBlock) + { + return node is MarkupElementSyntax or MarkupBlockSyntax; + } + + return node is MarkupElementSyntax; + } + + private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) + { + return node is MarkupElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); + } + + private static HashSet AddComponentDependenciesInRange(SyntaxNode root, int extractStart, int extractEnd) + { + var dependencies = new HashSet(); + var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); + + // Only analyze nodes within the extract span + foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) + { + if (node is MarkupTagHelperElementSyntax) + { + var tagHelperInfo = GetTagHelperInfo(node); + if (tagHelperInfo is not null) + { + AddDependenciesFromTagHelperInfo(tagHelperInfo, ref dependencies); + } + } + } + + return dependencies; + } + + private static TagHelperInfo? GetTagHelperInfo(SyntaxNode node) + { + if (node is MarkupTagHelperElementSyntax markupElement) + { + return markupElement.TagHelperInfo; + } + + return null; + } + + private static void AddDependenciesFromTagHelperInfo(TagHelperInfo tagHelperInfo, ref HashSet dependencies) + { + foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) + { + if (descriptor is not null) + { + foreach (var metadata in descriptor.Metadata) + { + if (metadata.Key == TagHelperMetadata.Common.TypeNamespace && + metadata.Value is not null && + !dependencies.Contains($"@using {metadata.Value}")) + { + dependencies.Add($"@using {metadata.Value}"); + } + } + } + } + } + + private async Task GenerateNewComponentAsync( + SelectionAnalysisResult selectionAnalysis, + RazorCodeDocument razorCodeDocument, + Uri componentUri, + DocumentContext documentContext, + Range relevantRange, + CancellationToken cancellationToken) + { + var contents = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); + if (contents is null) + { + return null; + } + + var dependencies = string.Join(Environment.NewLine, selectionAnalysis.ComponentDependencies); + var extractedContents = contents.GetSubTextString(new CodeAnalysis.Text.TextSpan(selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)).Trim(); + var newFileContent = $"{dependencies}{(dependencies.Length > 0 ? Environment.NewLine + Environment.NewLine : "")}{extractedContents}"; + + // Get CSharpStatements within component + var syntaxTree = razorCodeDocument.GetSyntaxTree(); + var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd); + + // Only make the Roslyn call if there is valid CSharp in the selected code. + if (cSharpCodeBlocks.Count == 0) + { + return newFileContent; + } + + if(!_documentVersionCache.TryGetDocumentVersion(documentContext.Snapshot, out var version)) + { + return newFileContent; + } + + var parameters = new RazorComponentInfoParams() + { + Project = new TextDocumentIdentifier + { + Uri = new Uri(documentContext.Project.FilePath, UriKind.Absolute) + }, + Document = new TextDocumentIdentifier + { + Uri = componentUri + }, + ScanRange = relevantRange, + HostDocumentVersion = version.Value + }; + + RazorComponentInfo? componentInfo; + + try + { + componentInfo = await _clientConnection.SendRequestAsync(CustomMessageNames.RazorComponentInfoEndpointName, parameters, cancellationToken: default).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to send request to RazorComponentInfoEndpoint", ex); + } + + // Check if client connection call was successful + if (componentInfo is null) + { + return newFileContent; + } + + return newFileContent; + } + + private static List GetCSharpCodeBlocks(RazorSyntaxTree syntaxTree, int start, int end) + { + var root = syntaxTree.Root; + var span = new TextSpan(start, end - start); + + // Get only CSharpSyntaxNodes without Razor Directives as children or ancestors. This avoids getting the @code block at the end of a razor file. + var razorDirectives = root.DescendantNodes() + .Where(node => node.SpanStart >= start && node.Span.End <= end) + .OfType(); + + var cSharpCodeBlocks = root.DescendantNodes() + .Where(node => span.Contains(node.Span)) + .OfType() + .Where(csharpNode => + !csharpNode.Ancestors().OfType().Any() && + !razorDirectives.Any(directive => directive.Span.Contains(csharpNode.Span))) + .ToList(); + + return cSharpCodeBlocks; + } + + // Get identifiers in code block to union with the identifiers in the extracted code + private static List GetIdentifiers(RazorSyntaxTree syntaxTree) + { + var identifiers = new List(); + var root = syntaxTree.Root; + // Get only the last CSharpCodeBlock (has an explicit "@code" transition) + var cSharpCodeBlock = root.DescendantNodes().OfType().LastOrDefault(); + + if (cSharpCodeBlock == null) + { + return identifiers; + } + + foreach (var node in cSharpCodeBlock.DescendantNodes()) + { + if (node is CSharpStatementLiteralSyntax literal && literal.Kind is Language.SyntaxKind.Identifier) + { + var lit = literal.ToFullString(); + } + } + + return identifiers; + + //var cSharpSyntaxNodes = cSharpCodeBlock.DescendantNodes().OfType<>(); + } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs new file mode 100644 index 00000000000..7d77fde78d9 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs @@ -0,0 +1,45 @@ +// 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.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; + +internal sealed record RazorComponentInfoParams +{ + [DataMember(Name = "document")] + [JsonPropertyName("document")] + public required TextDocumentIdentifier Document { get; set; } + + [DataMember(Name = "project")] + [JsonPropertyName("project")] + public required TextDocumentIdentifier Project { get; set; } + + [DataMember(Name = "hostDocumentVersion")] + [JsonPropertyName("hostDocumentVersion")] + public required int HostDocumentVersion { get; set; } + + [DataMember(Name = "scanRange")] + [JsonPropertyName("scanRange")] + public required Range ScanRange { get; init; } +} + +// Not sure where to put these two records +internal sealed record RazorComponentInfo +{ + public required HashSet? Methods { get; init; } + public required HashSet? Fields { get; init; } +} + +internal sealed record MethodInsideRazorElementInfo +{ + public required string Name { get; set; } + + public required string ReturnType { get; set; } + + public required List? ParameterTypes { get; set; } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CustomMessageNames.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CustomMessageNames.cs index ba9721274c9..860416225ba 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CustomMessageNames.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CustomMessageNames.cs @@ -13,6 +13,7 @@ namespace Microsoft.CodeAnalysis.Razor.Protocol; internal static class CustomMessageNames { // VS Windows only + public const string RazorComponentInfoEndpointName = "razor/razorComponentInfo"; public const string RazorInlineCompletionEndpoint = "razor/inlineCompletion"; public const string RazorValidateBreakpointRangeName = "razor/validateBreakpointRange"; public const string RazorOnAutoInsertEndpointName = "razor/onAutoInsert"; diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs index 57d2748e1bd..c786c231881 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs @@ -54,9 +54,19 @@ public async override Task> ReinvokeRequestOnServerAsync< throw new ArgumentException("message", nameof(method)); } - var response = await _languageServiceBroker.RequestAsync( + + TOut? response; + try + { + response = await _languageServiceBroker.RequestAsync( new GeneralRequest { LanguageServerName = languageServerName, Method = method, Request = parameters }, cancellationToken); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to invoke language server '{languageServerName}' with method '{method}'.", ex); + } + // No callers actually use the languageClient when handling the response. var result = response is not null ? new ReinvokeResponse(languageClient: null!, response) : default; diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs new file mode 100644 index 00000000000..0d14a446fe4 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using StreamJsonRpc; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Endpoints; + +internal partial class RazorCustomMessageTarget +{ + [JsonRpcMethod(CustomMessageNames.RazorComponentInfoEndpointName, UseSingleObjectParameterDeserialization = true)] + public async Task RazorComponentInfoAsync(RazorComponentInfoParams request, CancellationToken cancellationToken) + { + var (synchronized, virtualDocumentSnapshot) = await TrySynchronizeVirtualDocumentAsync(request.HostDocumentVersion, request.Document, cancellationToken); + if (!synchronized || virtualDocumentSnapshot is null) + { + return null; + } + + request.Document.Uri = virtualDocumentSnapshot.Uri; + + // This endpoint is special because it deals with a file that doesn't exist yet, so there is no document syncing necessary! + var response = await _requestInvoker.ReinvokeRequestOnServerAsync( + RazorLSPConstants.RoslynRazorComponentInfoEndpointName, + RazorLSPConstants.RazorCSharpLanguageServerName, + request, + cancellationToken).ConfigureAwait(false); + + return response.Result; + } +} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLSPConstants.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLSPConstants.cs index e94cfd7054b..4e3875d8a7e 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLSPConstants.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLSPConstants.cs @@ -27,5 +27,7 @@ internal static class RazorLSPConstants public const string RoslynFormatNewFileEndpointName = "roslyn/formatNewFile"; + public const string RoslynRazorComponentInfoEndpointName = "roslyn/razorComponentInfo"; + public const string RoslynSemanticTokenRangesEndpointName = "roslyn/semanticTokenRanges"; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs index e86b9e3e225..1b76f097d65 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; +using Microsoft.AspNetCore.Razor.LanguageServer.Test; using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; @@ -25,6 +26,7 @@ using Microsoft.CodeAnalysis.ExternalAccess.Razor; using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Testing; @@ -33,6 +35,7 @@ using Roslyn.Test.Utilities; using Xunit; using Xunit.Abstractions; +using static Microsoft.AspNetCore.Razor.LanguageServer.Formatting.FormattingLanguageServerTestBase; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs index 084eda2ea94..a5b84a339ef 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs @@ -175,7 +175,7 @@ public string someFunc(int num) { var codeAction = Assert.Single(commandOrCodeActionContainer); var razorCodeActionResolutionParams = ((JsonElement)codeAction.Data!).Deserialize(); Assert.NotNull(razorCodeActionResolutionParams); - var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize(); + var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize(); Assert.NotNull(actionParams); } From 2ec6a8da117c88af6738071088703616b239462e Mon Sep 17 00:00:00 2001 From: marcarro Date: Sun, 18 Aug 2024 21:00:02 -0700 Subject: [PATCH 09/29] Handle code case: Method and field promotion --- .../ExtractToComponentCodeActionResolver.cs | 361 +++++++++++++++--- .../CodeActions/RazorComponentInfoParams.cs | 24 +- ...rCustomMessageTarget_RazorComponentInfo.cs | 21 +- 3 files changed, 338 insertions(+), 68 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 54a0a2d5bd5..c19e1c76f93 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -36,6 +36,7 @@ using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; using static Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor.ExtractToComponentCodeActionProvider; using Microsoft.VisualStudio.Text; +using ICSharpCode.Decompiler.CSharp.Syntax; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -61,48 +62,42 @@ internal sealed class ExtractToComponentCodeActionResolver( public async Task ResolveAsync(JsonElement data, CancellationToken cancellationToken) { - if (data.ValueKind == JsonValueKind.Undefined) + var actionParams = DeserializeActionParams(data); + if (actionParams is null) { return null; } - var actionParams = JsonSerializer.Deserialize(data.GetRawText()); - if (actionParams is null) + if (!_documentContextFactory.TryCreate(actionParams.Uri, out var documentContext)) { return null; } - if (!_documentContextFactory.TryCreate(actionParams.Uri, out var documentContext)) + var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + if (codeDocument.IsUnsupported()) { return null; } - var componentDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); - if (componentDocument.IsUnsupported()) + if (!FileKinds.IsComponent(codeDocument.GetFileKind())) { return null; } - var selectionAnalysis = TryAnalyzeSelection(componentDocument, actionParams); - + var selectionAnalysis = TryAnalyzeSelection(codeDocument, actionParams); if (!selectionAnalysis.Success) { return null; } - var start = componentDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractStart); - var end = componentDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractEnd); + var start = codeDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractStart); + var end = codeDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractEnd); var removeRange = new Range { Start = new Position(start.Line, start.Character), End = new Position(end.Line, end.Character) }; - if (!FileKinds.IsComponent(componentDocument.GetFileKind())) - { - return null; - } - var path = FilePathNormalizer.Normalize(actionParams.Uri.GetAbsoluteOrUNCPath()); var directoryName = Path.GetDirectoryName(path).AssumeNotNull(); var templatePath = Path.Combine(directoryName, "Component"); @@ -121,13 +116,15 @@ internal sealed class ExtractToComponentCodeActionResolver( }.Uri; var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentContent = await GenerateNewComponentAsync(selectionAnalysis, componentDocument, actionParams.Uri, documentContext, removeRange, cancellationToken).ConfigureAwait(false); + var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, newComponentUri, documentContext, removeRange, cancellationToken).ConfigureAwait(false); - if (newComponentContent is null) + if (newComponentResult is null) { return null; } - + var newComponentContent = newComponentResult.NewContents; + var componentNameAndParams = GenerateComponentNameAndParameters(newComponentResult.Methods, componentName); + var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; @@ -141,7 +138,7 @@ internal sealed class ExtractToComponentCodeActionResolver( [ new TextEdit { - NewText = $"<{componentName} />", + NewText = $"<{componentNameAndParams}/>", Range = removeRange, } ], @@ -176,7 +173,7 @@ internal sealed class ExtractToComponentCodeActionResolver( // { "trimFinalNewlines", true }, // }, //}; - + //TextEdit[]? formattedEdits; //try //{ @@ -196,29 +193,28 @@ internal sealed class ExtractToComponentCodeActionResolver( DocumentChanges = documentChanges, }; } + private ExtractToComponentCodeActionParams? DeserializeActionParams(JsonElement data) + { + return data.ValueKind == JsonValueKind.Undefined + ? null + : JsonSerializer.Deserialize(data.GetRawText()); + } internal sealed record SelectionAnalysisResult { public required bool Success; - public required int ExtractStart; - public required int ExtractEnd; - public required HashSet ComponentDependencies; + public int ExtractStart; + public int ExtractEnd; + public HashSet? ComponentDependencies; + public HashSet? VariableDependencies; } private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) { - var result = new SelectionAnalysisResult - { - Success = false, - ExtractStart = 0, - ExtractEnd = 0, - ComponentDependencies = [], - }; - var (startElementNode, endElementNode) = GetStartAndEndElements(codeDocument, actionParams); if (startElementNode is null) { - return result; + return new SelectionAnalysisResult { Success = false }; } endElementNode ??= startElementNode; @@ -226,14 +222,17 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod var (success, extractStart, extractEnd) = TryProcessMultiPointSelection(startElementNode, endElementNode, codeDocument, actionParams); var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - var dependencies = AddComponentDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); + var methodDependencies = AddComponentDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); + var variableDependencies = AddVariableDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); - result.Success = success; - result.ExtractStart = extractStart; - result.ExtractEnd = extractEnd; - result.ComponentDependencies = dependencies; - - return result; + return new SelectionAnalysisResult + { + Success = success, + ExtractStart = extractStart, + ExtractEnd = extractEnd, + ComponentDependencies = methodDependencies, + VariableDependencies = variableDependencies, + }; } private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAndEndElements(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) @@ -305,12 +304,12 @@ private static bool IsInsideProperHtmlContent(int absoluteIndex, MarkupElementSy endOwner = previousSibling; } - return endOwner?.FirstAncestorOrSelf(); + return endOwner.FirstAncestorOrSelf(); } private static SourceLocation? GetEndLocation(Position selectionEnd, SourceText sourceText) { - if (!selectionEnd.TryGetSourceLocation(sourceText, logger: default, out var location)) + if (!selectionEnd.TryGetSourceLocation(sourceText, logger: default, out var location)) { return null; } @@ -504,6 +503,28 @@ private static HashSet AddComponentDependenciesInRange(SyntaxNode root, return dependencies; } + private static HashSet AddVariableDependenciesInRange(SyntaxNode root, int extractStart, int extractEnd) + { + var dependencies = new HashSet(); + var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); + + var candidates = root.DescendantNodes().Where(node => extractSpan.Contains(node.Span)); + + foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) + { + if (node is MarkupTagHelperAttributeValueSyntax tagAttribute) + { + dependencies.Add(tagAttribute.ToFullString()); + } + else if (node is CSharpImplicitExpressionBodySyntax implicitExpression) + { + dependencies.Add(implicitExpression.ToFullString()); + } + } + + return dependencies; + } + private static TagHelperInfo? GetTagHelperInfo(SyntaxNode node) { if (node is MarkupTagHelperElementSyntax markupElement) @@ -533,10 +554,11 @@ metadata.Value is not null && } } - private async Task GenerateNewComponentAsync( + private async Task GenerateNewComponentAsync( SelectionAnalysisResult selectionAnalysis, RazorCodeDocument razorCodeDocument, Uri componentUri, + Uri newComponentUri, DocumentContext documentContext, Range relevantRange, CancellationToken cancellationToken) @@ -551,19 +573,48 @@ metadata.Value is not null && var extractedContents = contents.GetSubTextString(new CodeAnalysis.Text.TextSpan(selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)).Trim(); var newFileContent = $"{dependencies}{(dependencies.Length > 0 ? Environment.NewLine + Environment.NewLine : "")}{extractedContents}"; + //var formattingParams = new FormatNewFileParams + //{ + // Project = new TextDocumentIdentifier + // { + // Uri = new Uri(documentContext.Project.FilePath, UriKind.Absolute) + // }, + // Document = new TextDocumentIdentifier + // { + // Uri = newComponentUri + // }, + // Contents = newFileContent + //}; + + //string fixedContent = string.Empty; + //try + //{ + // fixedContent = await _clientConnection.SendRequestAsync(CustomMessageNames.RazorFormatNewFileEndpointName, formattingParams, cancellationToken: default).ConfigureAwait(false); + //} + //catch (Exception ex) + //{ + // throw new InvalidOperationException("Failed to send request to RazorFormatNewFileEndpoint", ex); + //} + // Get CSharpStatements within component var syntaxTree = razorCodeDocument.GetSyntaxTree(); var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd); + var result = new NewRazorComponentInfo + { + NewContents = newFileContent, + Methods = [] + }; + // Only make the Roslyn call if there is valid CSharp in the selected code. if (cSharpCodeBlocks.Count == 0) { - return newFileContent; + return result; } - if(!_documentVersionCache.TryGetDocumentVersion(documentContext.Snapshot, out var version)) + if (!_documentVersionCache.TryGetDocumentVersion(documentContext.Snapshot, out var version)) { - return newFileContent; + return result; } var parameters = new RazorComponentInfoParams() @@ -576,7 +627,11 @@ metadata.Value is not null && { Uri = componentUri }, - ScanRange = relevantRange, + NewDocument = new TextDocumentIdentifier + { + Uri = newComponentUri + }, + NewContents = newFileContent, HostDocumentVersion = version.Value }; @@ -594,10 +649,27 @@ metadata.Value is not null && // Check if client connection call was successful if (componentInfo is null) { - return newFileContent; + return result; } - return newFileContent; + var codeBlockAtEnd = GetCodeBlockAtEnd(syntaxTree); + var identifiersInCodeBlock = GetIdentifiersInContext(codeBlockAtEnd, cSharpCodeBlocks); + + var methodsInFile = componentInfo.Methods.Select(method => method.Name).ToHashSet(); + var methodStringsInContext = methodsInFile.Intersect(identifiersInCodeBlock); + var methodsInContext = GetMethodsInContext(componentInfo, methodStringsInContext); + + var promotedMethods = GeneratePromotedMethods(methodsInContext); + var forwardedFields = GenerateForwardedConstantFields(codeBlockAtEnd, GetFieldsInContext(componentInfo, identifiersInCodeBlock)); + var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, forwardedFields); + + newFileContent = ReplaceMethodInvocations(newFileContent, methodsInContext); + newFileContent += newFileCodeBlock; + + result.NewContents = newFileContent; + result.Methods = methodsInContext; + + return result; } private static List GetCSharpCodeBlocks(RazorSyntaxTree syntaxTree, int start, int end) @@ -622,28 +694,201 @@ private static List GetCSharpCodeBlocks(RazorSyntaxTree s } // Get identifiers in code block to union with the identifiers in the extracted code - private static List GetIdentifiers(RazorSyntaxTree syntaxTree) + private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd, List previousCodeBlocks) + { + var identifiersInLastCodeBlock = new HashSet(); + var identifiersInPreviousCodeBlocks = new HashSet(); + + if (codeBlockAtEnd == null) + { + return identifiersInLastCodeBlock; + } + + foreach (var node in codeBlockAtEnd.DescendantNodes()) + { + if (node.Kind is Language.SyntaxKind.Identifier) + { + var lit = node.ToFullString(); + identifiersInLastCodeBlock.Add(lit); + } + } + + foreach (var previousCodeBlock in previousCodeBlocks) + { + foreach (var node in previousCodeBlock.DescendantNodes()) + { + if (node.Kind is Language.SyntaxKind.Identifier) + { + var lit = node.ToFullString(); + identifiersInPreviousCodeBlocks.Add(lit); + } + } + } + + // Now union with identifiers in other cSharpCodeBlocks in context + identifiersInLastCodeBlock.IntersectWith(identifiersInPreviousCodeBlocks); + + return identifiersInLastCodeBlock; + } + + private static HashSet? GetMethodsInContext(RazorComponentInfo componentInfo, IEnumerable methodStringsInContext) + { + var methodsInContext = new HashSet(); + foreach (var componentMethod in componentInfo.Methods) + { + if (methodStringsInContext.Contains(componentMethod.Name) && !methodsInContext.Any(method => method.Name == componentMethod.Name)) + { + methodsInContext.Add(componentMethod); + } + } + return methodsInContext; + } + + private static SyntaxNode? GetCodeBlockAtEnd(RazorSyntaxTree syntaxTree) { - var identifiers = new List(); var root = syntaxTree.Root; + // Get only the last CSharpCodeBlock (has an explicit "@code" transition) - var cSharpCodeBlock = root.DescendantNodes().OfType().LastOrDefault(); + var razorDirectiveAtEnd = root.DescendantNodes().OfType().LastOrDefault(); - if (cSharpCodeBlock == null) + if (razorDirectiveAtEnd is null) { - return identifiers; + return null; + } + + return razorDirectiveAtEnd.Parent; + } + + // Create a series of [Parameter] attributes for extracted methods. + // Void return functions are promoted to Action delegates. + // All other functions should be Func delegates. + private static string GeneratePromotedMethods(HashSet methods) + { + var builder = new StringBuilder(); + var parameterCount = 0; + var totalMethods = methods.Count; + + foreach (var method in methods) + { + builder.AppendLine("/// "); + builder.AppendLine($"/// Delegate for the '{method.Name}' method."); + builder.AppendLine("/// "); + builder.AppendLine("[Parameter]"); + builder.Append("public "); + + if (method.ReturnType == "void") + { + builder.Append("Action"); + } + else + { + builder.Append("Func<"); + } + + if (method.ParameterTypes.Count > 0) + { + if (method.ReturnType == "void") + { + builder.Append("<"); + } + + builder.Append(string.Join(", ", method.ParameterTypes)); + if (method.ReturnType != "void") + { + builder.Append(", "); + } + } + + if (method.ReturnType != "void") + { + builder.Append(method.ReturnType); + } + + builder.Append($"{(method.ReturnType == "void" ? (method.ParameterTypes.Count > 0 ? ">" : "") : ">")}? Parameter{(parameterCount > 0 ? parameterCount : "")} {{ get; set; }}"); + if (parameterCount < totalMethods - 1) + { + builder.AppendLine(); + builder.AppendLine(); + } + + parameterCount++; } + return builder.ToString(); + } + + private static string GenerateForwardedConstantFields(SyntaxNode codeBlockAtEnd, HashSet relevantFields) + { + var builder = new StringBuilder(); - foreach (var node in cSharpCodeBlock.DescendantNodes()) + var codeBlockString = codeBlockAtEnd.ToFullString(); + + var lines = codeBlockString.Split('\n'); + foreach (var line in lines) { - if (node is CSharpStatementLiteralSyntax literal && literal.Kind is Language.SyntaxKind.Identifier) + if (relevantFields.Any(field => line.Contains(field))) { - var lit = literal.ToFullString(); + builder.AppendLine(line.Trim()); } } - return identifiers; + return builder.ToString(); + } - //var cSharpSyntaxNodes = cSharpCodeBlock.DescendantNodes().OfType<>(); + // GetFieldsInContext(componentInfo, identifiersInCodeBlock) + private static HashSet? GetFieldsInContext(RazorComponentInfo componentInfo, HashSet identifiersInCodeBlock) + { + var identifiersInFile = componentInfo.Fields.Select(field => field.Name).ToHashSet(); + return identifiersInFile.Intersect(identifiersInCodeBlock).ToHashSet(); + } + + private static string GenerateNewFileCodeBlock(string promotedMethods, string carryoverFields) + { + var builder = new StringBuilder(); + builder.AppendLine(); + builder.AppendLine(); + builder.AppendLine("@code {"); + builder.AppendLine(carryoverFields); + builder.AppendLine(promotedMethods); + builder.AppendLine("}"); + return builder.ToString(); + } + + // Method invocations in the new file must be replaced with their respective parameter name. This is simply a case of replacing each string. + private static string ReplaceMethodInvocations(string newFileContent, HashSet methods) + { + var parameterCount = 0; + foreach (var method in methods) + { + newFileContent = newFileContent.Replace(method.Name, $"Parameter{(parameterCount > 0 ? parameterCount : "")}"); + parameterCount++; + } + return newFileContent; + } + + private static string GenerateComponentNameAndParameters(HashSet? methods, string componentName) + { + var builder = new StringBuilder(); + builder.Append(componentName + " "); + var parameterCount = 0; + + if (methods is null) + { + return builder.ToString(); + } + + foreach (var method in methods) + { + builder.Append($"Parameter{(parameterCount > 0 ? parameterCount : "")}"); + builder.Append($"={method.Name}"); + builder.Append(" "); + parameterCount++; + } + return builder.ToString(); + } + + internal sealed record NewRazorComponentInfo + { + public required string NewContents { get; set; } + public required HashSet? Methods { get; set; } } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs index 7d77fde78d9..a9b681291b2 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs @@ -2,9 +2,12 @@ // 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.Runtime.Serialization; using System.Text.Json.Serialization; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Remote; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; @@ -15,6 +18,10 @@ internal sealed record RazorComponentInfoParams [JsonPropertyName("document")] public required TextDocumentIdentifier Document { get; set; } + [DataMember(Name = "newDocument")] + [JsonPropertyName("newDocument")] + public required TextDocumentIdentifier NewDocument { get; set; } + [DataMember(Name = "project")] [JsonPropertyName("project")] public required TextDocumentIdentifier Project { get; set; } @@ -23,16 +30,17 @@ internal sealed record RazorComponentInfoParams [JsonPropertyName("hostDocumentVersion")] public required int HostDocumentVersion { get; set; } - [DataMember(Name = "scanRange")] - [JsonPropertyName("scanRange")] - public required Range ScanRange { get; init; } + [DataMember(Name = "newContents")] + [JsonPropertyName("newContents")] + public required string NewContents { get; set; } } // Not sure where to put these two records internal sealed record RazorComponentInfo { - public required HashSet? Methods { get; init; } - public required HashSet? Fields { get; init; } + public required List? Methods { get; set; } + public required List? Fields { get; set; } + } internal sealed record MethodInsideRazorElementInfo @@ -43,3 +51,9 @@ internal sealed record MethodInsideRazorElementInfo public required List? ParameterTypes { get; set; } } + +internal sealed record SymbolInsideRazorElementInfo +{ + public required string Name { get; set; } + public required string Type { get; set; } +} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs index 0d14a446fe4..4202afea695 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs @@ -1,10 +1,12 @@ // 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.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; using Microsoft.VisualStudio.LanguageServer.Protocol; using StreamJsonRpc; @@ -23,12 +25,21 @@ internal partial class RazorCustomMessageTarget request.Document.Uri = virtualDocumentSnapshot.Uri; + ReinvokeResponse response; + // This endpoint is special because it deals with a file that doesn't exist yet, so there is no document syncing necessary! - var response = await _requestInvoker.ReinvokeRequestOnServerAsync( - RazorLSPConstants.RoslynRazorComponentInfoEndpointName, - RazorLSPConstants.RazorCSharpLanguageServerName, - request, - cancellationToken).ConfigureAwait(false); + try + { + response = await _requestInvoker.ReinvokeRequestOnServerAsync( + RazorLSPConstants.RoslynRazorComponentInfoEndpointName, + RazorLSPConstants.RazorCSharpLanguageServerName, + request, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed at Endpoint: Failed to retrieve Razor component information.", ex); + } return response.Result; } From 78a6d4c794ac452dec1ee60ace2ff2714d851f4b Mon Sep 17 00:00:00 2001 From: marcarro Date: Mon, 19 Aug 2024 08:55:31 -0700 Subject: [PATCH 10/29] Nits and reverted some changes that werent supposed to make it --- .../ExtractToComponentCodeActionResolver.cs | 99 +++++++------------ .../CodeActions/RazorComponentInfoParams.cs | 10 +- .../DefaultLSPRequestInvoker.cs | 16 +-- ...rCustomMessageTarget_RazorComponentInfo.cs | 1 - 4 files changed, 43 insertions(+), 83 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index c19e1c76f93..20b5bf0e40d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -116,12 +116,13 @@ internal sealed class ExtractToComponentCodeActionResolver( }.Uri; var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, newComponentUri, documentContext, removeRange, cancellationToken).ConfigureAwait(false); + var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, newComponentUri, documentContext, cancellationToken).ConfigureAwait(false); if (newComponentResult is null) { return null; } + var newComponentContent = newComponentResult.NewContents; var componentNameAndParams = GenerateComponentNameAndParameters(newComponentResult.Methods, componentName); @@ -157,37 +158,6 @@ internal sealed class ExtractToComponentCodeActionResolver( } }; - //if (!_documentContextFactory.TryCreateForOpenDocument(newComponentUri, out var versionedDocumentContext)) - //{ - // throw new InvalidOperationException("Failed to create a versioned document context for the new component"); - //} - - //var formattingOptions = new VisualStudio.LanguageServer.Protocol.FormattingOptions() - //{ - // TabSize = _razorLSPOptionsMonitor.CurrentValue.TabSize, - // InsertSpaces = _razorLSPOptionsMonitor.CurrentValue.InsertSpaces, - // OtherOptions = new Dictionary - // { - // { "trimTrailingWhitespace", true }, - // { "insertFinalNewline", true }, - // { "trimFinalNewlines", true }, - // }, - //}; - - //TextEdit[]? formattedEdits; - //try - //{ - // formattedEdits = await _razorFormattingService.FormatAsync( - // documentContext, - // range: removeRange, - // formattingOptions, - // cancellationToken: default).ConfigureAwait(false); - //} - //catch (Exception ex) - //{ - // throw new InvalidOperationException("Failed to format the new component", ex); - //} - return new WorkspaceEdit { DocumentChanges = documentChanges, @@ -560,7 +530,6 @@ metadata.Value is not null && Uri componentUri, Uri newComponentUri, DocumentContext documentContext, - Range relevantRange, CancellationToken cancellationToken) { var contents = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); @@ -569,33 +538,13 @@ metadata.Value is not null && return null; } - var dependencies = string.Join(Environment.NewLine, selectionAnalysis.ComponentDependencies); + var dependencies = selectionAnalysis.ComponentDependencies is not null + ? string.Join(Environment.NewLine, selectionAnalysis.ComponentDependencies) + : string.Empty; + var extractedContents = contents.GetSubTextString(new CodeAnalysis.Text.TextSpan(selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)).Trim(); var newFileContent = $"{dependencies}{(dependencies.Length > 0 ? Environment.NewLine + Environment.NewLine : "")}{extractedContents}"; - //var formattingParams = new FormatNewFileParams - //{ - // Project = new TextDocumentIdentifier - // { - // Uri = new Uri(documentContext.Project.FilePath, UriKind.Absolute) - // }, - // Document = new TextDocumentIdentifier - // { - // Uri = newComponentUri - // }, - // Contents = newFileContent - //}; - - //string fixedContent = string.Empty; - //try - //{ - // fixedContent = await _clientConnection.SendRequestAsync(CustomMessageNames.RazorFormatNewFileEndpointName, formattingParams, cancellationToken: default).ConfigureAwait(false); - //} - //catch (Exception ex) - //{ - // throw new InvalidOperationException("Failed to send request to RazorFormatNewFileEndpoint", ex); - //} - // Get CSharpStatements within component var syntaxTree = razorCodeDocument.GetSyntaxTree(); var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd); @@ -653,14 +602,25 @@ metadata.Value is not null && } var codeBlockAtEnd = GetCodeBlockAtEnd(syntaxTree); + if (codeBlockAtEnd is null) + { + return result; + } + var identifiersInCodeBlock = GetIdentifiersInContext(codeBlockAtEnd, cSharpCodeBlocks); + if (componentInfo.Methods is null) + { + return result; + } + var methodsInFile = componentInfo.Methods.Select(method => method.Name).ToHashSet(); var methodStringsInContext = methodsInFile.Intersect(identifiersInCodeBlock); var methodsInContext = GetMethodsInContext(componentInfo, methodStringsInContext); - var promotedMethods = GeneratePromotedMethods(methodsInContext); - var forwardedFields = GenerateForwardedConstantFields(codeBlockAtEnd, GetFieldsInContext(componentInfo, identifiersInCodeBlock)); + + var fieldsInContext = GetFieldsInContext(componentInfo, identifiersInCodeBlock); + var forwardedFields = GenerateForwardedConstantFields(codeBlockAtEnd, fieldsInContext); var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, forwardedFields); newFileContent = ReplaceMethodInvocations(newFileContent, methodsInContext); @@ -731,9 +691,14 @@ private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd return identifiersInLastCodeBlock; } - private static HashSet? GetMethodsInContext(RazorComponentInfo componentInfo, IEnumerable methodStringsInContext) + private static HashSet GetMethodsInContext(RazorComponentInfo componentInfo, IEnumerable methodStringsInContext) { var methodsInContext = new HashSet(); + if (componentInfo.Methods is null) + { + return methodsInContext; + } + foreach (var componentMethod in componentInfo.Methods) { if (methodStringsInContext.Contains(componentMethod.Name) && !methodsInContext.Any(method => method.Name == componentMethod.Name)) @@ -741,6 +706,7 @@ private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd methodsInContext.Add(componentMethod); } } + return methodsInContext; } @@ -804,7 +770,8 @@ private static string GeneratePromotedMethods(HashSet 0 ? ">" : "") : ">")}? Parameter{(parameterCount > 0 ? parameterCount : "")} {{ get; set; }}"); + builder.Append($"{(method.ReturnType == "void" ? (method.ParameterTypes.Count > 0 ? ">" : "") : ">")}? " + + $"Parameter{(parameterCount > 0 ? parameterCount : "")} {{ get; set; }}"); if (parameterCount < totalMethods - 1) { builder.AppendLine(); @@ -813,6 +780,7 @@ private static string GeneratePromotedMethods(HashSet? GetFieldsInContext(RazorComponentInfo componentInfo, HashSet identifiersInCodeBlock) + private static HashSet GetFieldsInContext(RazorComponentInfo componentInfo, HashSet identifiersInCodeBlock) { + if (componentInfo.Fields is null) + { + return []; + } + var identifiersInFile = componentInfo.Fields.Select(field => field.Name).ToHashSet(); return identifiersInFile.Intersect(identifiersInCodeBlock).ToHashSet(); } @@ -862,6 +835,7 @@ private static string ReplaceMethodInvocations(string newFileContent, HashSet 0 ? parameterCount : "")}"); parameterCount++; } + return newFileContent; } @@ -883,6 +857,7 @@ private static string GenerateComponentNameAndParameters(HashSet? Methods { get; set; } - public required List? Fields { get; set; } - + public required List Methods { get; set; } + public required List Fields { get; set; } } internal sealed record MethodInsideRazorElementInfo @@ -49,7 +45,7 @@ internal sealed record MethodInsideRazorElementInfo public required string ReturnType { get; set; } - public required List? ParameterTypes { get; set; } + public required List ParameterTypes { get; set; } } internal sealed record SymbolInsideRazorElementInfo diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs index c786c231881..31030043325 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs @@ -54,19 +54,9 @@ public async override Task> ReinvokeRequestOnServerAsync< throw new ArgumentException("message", nameof(method)); } - - TOut? response; - try - { - response = await _languageServiceBroker.RequestAsync( - new GeneralRequest { LanguageServerName = languageServerName, Method = method, Request = parameters }, - cancellationToken); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to invoke language server '{languageServerName}' with method '{method}'.", ex); - } - + var response = await _languageServiceBroker.RequestAsync( + new GeneralRequest { LanguageServerName = languageServerName, Method = method, Request = parameters }, + cancellationToken); // No callers actually use the languageClient when handling the response. var result = response is not null ? new ReinvokeResponse(languageClient: null!, response) : default; diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs index 4202afea695..05f0a942fc7 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs @@ -7,7 +7,6 @@ using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; -using Microsoft.VisualStudio.LanguageServer.Protocol; using StreamJsonRpc; namespace Microsoft.VisualStudio.Razor.LanguageClient.Endpoints; From 5278bd89cf574e36d512a6e9f44aaeba7a8cfd8d Mon Sep 17 00:00:00 2001 From: marcarro Date: Mon, 19 Aug 2024 11:58:37 -0700 Subject: [PATCH 11/29] Added support for MarkupTagHelperElements --- .../ExtractToComponentCodeActionProvider.cs | 31 +++++++---- .../ExtractToComponentCodeActionResolver.cs | 52 +++++-------------- 2 files changed, 34 insertions(+), 49 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index fd549d0d8ee..0ca23da7504 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -83,23 +83,32 @@ private static bool IsValidContext(RazorCodeActionContext context) private static bool IsSelectionValid(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) { var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); - var startElementNode = owner?.FirstAncestorOrSelf(); - return startElementNode is not null && !IsInsideProperHtmlContent(context, startElementNode) && !HasDiagnosticErrors(startElementNode); + if (owner is null) + { + return false; + } + + var startElementNode = owner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); + return startElementNode is not null && !HasDiagnosticErrors(startElementNode) && !IsInsideProperHtmlContent(context, owner); } - private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, MarkupElementSyntax startElementNode) + private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, SyntaxNode owner) { - // If the provider executes before the user/completion inserts an end tag, the below return fails - if (startElementNode.EndTag.IsMissing) - { - return true; - } + var tryMakeMarkupElement = owner.FirstAncestorOrSelf(); + var tryMakeMarkupTagHelperElement = owner.FirstAncestorOrSelf(); + + var isLocationInProperMarkupElement = tryMakeMarkupElement is not null && + context.Location.AbsoluteIndex > tryMakeMarkupElement.StartTag.Span.End && + context.Location.AbsoluteIndex < tryMakeMarkupElement.EndTag.SpanStart; + + var isLocationInProperMarkupTagHelper = tryMakeMarkupTagHelperElement is not null && + context.Location.AbsoluteIndex > tryMakeMarkupTagHelperElement.StartTag.Span.End && + context.Location.AbsoluteIndex < tryMakeMarkupTagHelperElement.EndTag.SpanStart; - return context.Location.AbsoluteIndex > startElementNode.StartTag.Span.End && - context.Location.AbsoluteIndex < startElementNode.EndTag.SpanStart; + return isLocationInProperMarkupElement || isLocationInProperMarkupTagHelper; } - private static bool HasDiagnosticErrors(MarkupElementSyntax markupElement) + private static bool HasDiagnosticErrors(MarkupSyntaxNode markupElement) { var diagnostics = markupElement.GetDiagnostics(); return diagnostics.Any(d => d.Severity == RazorDiagnosticSeverity.Error); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 20b5bf0e40d..1305c216e41 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -125,7 +125,7 @@ internal sealed class ExtractToComponentCodeActionResolver( var newComponentContent = newComponentResult.NewContents; var componentNameAndParams = GenerateComponentNameAndParameters(newComponentResult.Methods, componentName); - + var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; @@ -176,7 +176,7 @@ internal sealed record SelectionAnalysisResult public int ExtractStart; public int ExtractEnd; public HashSet? ComponentDependencies; - public HashSet? VariableDependencies; + public HashSet? TentativeVariableDependencies; } private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) @@ -192,7 +192,7 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod var (success, extractStart, extractEnd) = TryProcessMultiPointSelection(startElementNode, endElementNode, codeDocument, actionParams); var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - var methodDependencies = AddComponentDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); + var componentDependencies = AddComponentDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); var variableDependencies = AddVariableDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); return new SelectionAnalysisResult @@ -200,12 +200,12 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod Success = success, ExtractStart = extractStart, ExtractEnd = extractEnd, - ComponentDependencies = methodDependencies, - VariableDependencies = variableDependencies, + ComponentDependencies = componentDependencies, + TentativeVariableDependencies = variableDependencies, }; } - private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAndEndElements(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) + private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndElements(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) { var syntaxTree = codeDocument.GetSyntaxTree(); if (syntaxTree is null) @@ -219,8 +219,8 @@ private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAn return (null, null); } - var startElementNode = owner.FirstAncestorOrSelf(); - if (startElementNode is null || IsInsideProperHtmlContent(actionParams.AbsoluteIndex, startElementNode)) + var startElementNode = owner.FirstAncestorOrSelf(node => node is MarkupTagHelperElementSyntax or MarkupElementSyntax); + if (startElementNode is null) { return (null, null); } @@ -236,19 +236,7 @@ private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAn return (startElementNode, endElementNode); } - private static bool IsInsideProperHtmlContent(int absoluteIndex, MarkupElementSyntax startElementNode) - { - // If the provider executes before the user/completion inserts an end tag, the below return fails - if (startElementNode.EndTag.IsMissing) - { - return true; - } - - return absoluteIndex > startElementNode.StartTag.Span.End && - absoluteIndex < startElementNode.EndTag.SpanStart; - } - - private static MarkupElementSyntax? TryGetEndElementNode(Position selectionStart, Position selectionEnd, RazorSyntaxTree syntaxTree, SourceText sourceText) + private static MarkupSyntaxNode? TryGetEndElementNode(Position selectionStart, Position selectionEnd, RazorSyntaxTree syntaxTree, SourceText sourceText) { if (selectionStart == selectionEnd) { @@ -274,7 +262,7 @@ private static bool IsInsideProperHtmlContent(int absoluteIndex, MarkupElementSy endOwner = previousSibling; } - return endOwner.FirstAncestorOrSelf(); + return endOwner.FirstAncestorOrSelf(node => node is MarkupTagHelperElementSyntax or MarkupElementSyntax); } private static SourceLocation? GetEndLocation(Position selectionEnd, SourceText sourceText) @@ -296,7 +284,7 @@ private static bool IsInsideProperHtmlContent(int absoluteIndex, MarkupElementSy /// The parameters for the extraction action, which will be updated. /// one more line for output /// A tuple containing a boolean indicating success, the start of the extraction range, and the end of the extraction range. - private static (bool success, int extractStart, int extractEnd) TryProcessMultiPointSelection(MarkupElementSyntax startElementNode, MarkupElementSyntax endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) + private static (bool success, int extractStart, int extractEnd) TryProcessMultiPointSelection(MarkupSyntaxNode startElementNode, MarkupSyntaxNode endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) { var extractStart = startElementNode.Span.Start; var extractEnd = endElementNode.Span.End; @@ -420,11 +408,9 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy private static SyntaxNode? FindNearestCommonAncestor(SyntaxNode node1, SyntaxNode node2) { var current = node1; - var secondNodeIsCodeBlock = node2 is CSharpCodeBlockSyntax; - while (current is not null) { - if (ShouldCheckNode(current, secondNodeIsCodeBlock) && current.Span.Contains(node2.Span)) + if (CheckNode(current) && current.Span.Contains(node2.Span)) { return current; } @@ -435,21 +421,11 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy return null; } - // Whenever the multi point selection includes a code block at the end, the logic for finding the nearest common ancestor and containing sibling pair - // should accept nodes of type MarkupBlockSyntax and CSharpCodeBlock each, respectively. ShouldCheckNode() and IsValidNode() handle these cases. - private static bool ShouldCheckNode(SyntaxNode node, bool isCodeBlock) - { - if (isCodeBlock) - { - return node is MarkupElementSyntax or MarkupBlockSyntax; - } - - return node is MarkupElementSyntax; - } + private static bool CheckNode(SyntaxNode node) => node is MarkupElementSyntax or MarkupTagHelperElementSyntax or MarkupBlockSyntax; private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) { - return node is MarkupElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); + return node is MarkupElementSyntax or MarkupTagHelperElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); } private static HashSet AddComponentDependenciesInRange(SyntaxNode root, int extractStart, int extractEnd) From 8cf4f661b44b829d2aa6f32bc7778de850c3e8e4 Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 21 Aug 2024 15:10:11 -0700 Subject: [PATCH 12/29] Adapted for roslyn endpoint rename, and added tests --- .../ExtractToComponentCodeActionProvider.cs | 2 +- .../ExtractToComponentCodeActionResolver.cs | 166 +++++-- ...InfoParams.cs => GetSymbolicInfoParams.cs} | 26 +- .../Protocol/CustomMessageNames.cs | 2 +- .../Protocol/LanguageServerConstants.cs | 2 +- ...zorCustomMessageTarget_GetSymbolicInfo.cs} | 12 +- .../LanguageClient/RazorLSPConstants.cs | 2 +- .../CodeActionEndToEndTest.NetFx.cs | 99 ++++- ...xtractToComponentCodeActionProviderTest.cs | 419 ------------------ 9 files changed, 245 insertions(+), 485 deletions(-) rename src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/{RazorComponentInfoParams.cs => GetSymbolicInfoParams.cs} (65%) rename src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/{RazorCustomMessageTarget_RazorComponentInfo.cs => RazorCustomMessageTarget_GetSymbolicInfo.cs} (70%) delete mode 100644 src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index 0ca23da7504..92f182b61ef 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -63,7 +63,7 @@ public Task> ProvideAsync(RazorCodeAct var resolutionParams = new RazorCodeActionResolutionParams() { - Action = LanguageServerConstants.CodeActions.ExtractToNewComponentAction, + Action = LanguageServerConstants.CodeActions.ExtractToComponentAction, Language = LanguageServerConstants.CodeActions.Languages.Razor, Data = actionParams, }; diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 1305c216e41..a8e9a35b246 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -37,6 +37,7 @@ using static Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor.ExtractToComponentCodeActionProvider; using Microsoft.VisualStudio.Text; using ICSharpCode.Decompiler.CSharp.Syntax; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -58,7 +59,7 @@ internal sealed class ExtractToComponentCodeActionResolver( private readonly IRazorFormattingService _razorFormattingService = razorFormattingService; private readonly IDocumentVersionCache _documentVersionCache = documentVersionCache; - public string Action => LanguageServerConstants.CodeActions.ExtractToNewComponentAction; + public string Action => LanguageServerConstants.CodeActions.ExtractToComponentAction; public async Task ResolveAsync(JsonElement data, CancellationToken cancellationToken) { @@ -116,7 +117,7 @@ internal sealed class ExtractToComponentCodeActionResolver( }.Uri; var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, newComponentUri, documentContext, cancellationToken).ConfigureAwait(false); + var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, newComponentUri, documentContext, removeRange, cancellationToken).ConfigureAwait(false); if (newComponentResult is null) { @@ -163,7 +164,7 @@ internal sealed class ExtractToComponentCodeActionResolver( DocumentChanges = documentChanges, }; } - private ExtractToComponentCodeActionParams? DeserializeActionParams(JsonElement data) + private static ExtractToComponentCodeActionParams? DeserializeActionParams(JsonElement data) { return data.ValueKind == JsonValueKind.Undefined ? null @@ -189,7 +190,17 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod endElementNode ??= startElementNode; - var (success, extractStart, extractEnd) = TryProcessMultiPointSelection(startElementNode, endElementNode, codeDocument, actionParams); + var success = TryProcessMultiPointSelection(startElementNode, + endElementNode, + codeDocument, + actionParams, + out var extractStart, + out var extractEnd); + + if (!success) + { + return new SelectionAnalysisResult { Success = false }; + } var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; var componentDependencies = AddComponentDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); @@ -276,32 +287,40 @@ private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndEl } /// - /// Processes a multi-point selection to determine the correct range for extraction. + /// Processes a multi-point selection, providing the start and end of the extraction range if successful. /// /// The starting element of the selection. /// The ending element of the selection, if it exists. /// The code document containing the selection. /// The parameters for the extraction action, which will be updated. - /// one more line for output - /// A tuple containing a boolean indicating success, the start of the extraction range, and the end of the extraction range. - private static (bool success, int extractStart, int extractEnd) TryProcessMultiPointSelection(MarkupSyntaxNode startElementNode, MarkupSyntaxNode endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) + /// The start of the extraction range. + /// The end of the extraction range + /// true if the selection was successfully processed; otherwise, false. + private static bool TryProcessMultiPointSelection(MarkupSyntaxNode startElementNode, MarkupSyntaxNode endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams, out int extractStart, out int extractEnd) { - var extractStart = startElementNode.Span.Start; - var extractEnd = endElementNode.Span.End; + extractStart = startElementNode.Span.Start; + extractEnd = endElementNode.Span.End; // Check if it's a multi-point selection if (actionParams.SelectStart == actionParams.SelectEnd) { - return (true, extractStart, extractEnd); + return true; } // Check if the start element is an ancestor of the end element or vice versa - var selectionStartHasParentElement = endElementNode.Ancestors().Any(node => node == startElementNode); - var selectionEndHasParentElement = startElementNode.Ancestors().Any(node => node == endElementNode); + var startNodeContainsEndNode = endElementNode.Ancestors().Any(node => node == startElementNode); + var endNodeContainsStartNode = startElementNode.Ancestors().Any(node => node == endElementNode); + + // If the start element is an ancestor of the end element (or vice versa), update the extraction + if (endNodeContainsStartNode) + { + extractStart = endElementNode.Span.Start; + } - // If the start element is an ancestor of the end element (or vice versa), update the extraction range - extractStart = selectionEndHasParentElement ? endElementNode.Span.Start : extractStart; - extractEnd = selectionStartHasParentElement ? startElementNode.Span.End : extractEnd; + if (startNodeContainsEndNode) + { + extractEnd = startElementNode.Span.End; + } // If the start element is not an ancestor of the end element (or vice versa), we need to find a common parent // This conditional handles cases where the user's selection spans across different levels of the DOM. @@ -316,7 +335,7 @@ private static (bool success, int extractStart, int extractEnd) TryProcessMultiP // Selected text ends here // // In this case, we need to find the smallest set of complete elements that covers the entire selection. - if (startElementNode != endElementNode && !(selectionStartHasParentElement || selectionEndHasParentElement)) + if (startElementNode != endElementNode && !(startNodeContainsEndNode || endNodeContainsStartNode)) { // Find the closest containing sibling pair that encompasses both the start and end elements var (selectStart, selectEnd) = FindContainingSiblingPair(startElementNode, endElementNode); @@ -327,20 +346,20 @@ private static (bool success, int extractStart, int extractEnd) TryProcessMultiP extractStart = selectStart.Span.Start; extractEnd = selectEnd.Span.End; - return (true, extractStart, extractEnd); + return true; } // Note: If we don't find a valid pair, we keep the original extraction range } if (startElementNode != endElementNode) { - return (true, extractStart, extractEnd); // Will only trigger when the end of the selection does not include a code block. + return true; // Will only trigger when the end of the selection does not include a code block. } var endLocation = GetEndLocation(actionParams.SelectEnd, codeDocument.GetSourceText()); if (!endLocation.HasValue) { - return (false, extractStart, extractEnd); + return false; } var endOwner = codeDocument.GetSyntaxTree().Root.FindInnermostNode(endLocation.Value.AbsoluteIndex, true); @@ -353,7 +372,7 @@ private static (bool success, int extractStart, int extractEnd) TryProcessMultiP if (endCodeBlock is null) { // One of the cases where this triggers is when a single element is multi-pointedly selected - return (true, extractStart, extractEnd); + return true; } var (withCodeBlockStart, withCodeBlockEnd) = FindContainingSiblingPair(startElementNode, endCodeBlock); @@ -362,7 +381,7 @@ private static (bool success, int extractStart, int extractEnd) TryProcessMultiP extractStart = withCodeBlockStart?.Span.Start ?? extractStart; extractEnd = withCodeBlockEnd?.Span.End ?? extractEnd; - return (true, extractStart, extractEnd); + return true; } private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) @@ -506,6 +525,7 @@ metadata.Value is not null && Uri componentUri, Uri newComponentUri, DocumentContext documentContext, + Range relevantRange, CancellationToken cancellationToken) { var contents = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); @@ -542,7 +562,35 @@ metadata.Value is not null && return result; } - var parameters = new RazorComponentInfoParams() + var sourceMappings = razorCodeDocument.GetCSharpDocument().SourceMappings; + + var sourceMappingRanges = sourceMappings.Select(m => + ( + new Range + { + Start = new Position(m.OriginalSpan.LineIndex, m.OriginalSpan.CharacterIndex), + End = new Position(m.OriginalSpan.LineIndex, m.OriginalSpan.EndCharacterIndex) + }, + m.GeneratedSpan + )).ToList(); + + var relevantTextSpan = relevantRange.ToTextSpan(razorCodeDocument.Source.Text); + var intersectingGeneratedSpans = sourceMappingRanges.Where(m => relevantRange.IntersectsOrTouches(m.Item1)).Select(m => m.GeneratedSpan).ToArray(); + var intersectingGeneratedRanges = intersectingGeneratedSpans.Select(m => + ( + new Range + { + Start = new Position(m.LineIndex, m.CharacterIndex), + End = new Position(m.LineIndex, m.CharacterIndex) + } + )).ToArray(); + + if (!TryMapToClosestGeneratedDocumentRange(razorCodeDocument.GetCSharpDocument(), selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd, out var selectionMappedRange)) + { + return result; + } + + var parameters = new GetSymbolicInfoParams() { Project = new TextDocumentIdentifier { @@ -557,14 +605,18 @@ metadata.Value is not null && Uri = newComponentUri }, NewContents = newFileContent, - HostDocumentVersion = version.Value + HostDocumentVersion = version.Value, + MappedRange = selectionMappedRange, + IntersectingSpansInGeneratedMappings = intersectingGeneratedRanges }; - RazorComponentInfo? componentInfo; - + SymbolicInfo? componentInfo; try { - componentInfo = await _clientConnection.SendRequestAsync(CustomMessageNames.RazorComponentInfoEndpointName, parameters, cancellationToken: default).ConfigureAwait(false); + componentInfo = await _clientConnection.SendRequestAsync( + CustomMessageNames.RazorGetSymbolicInfoEndpointName, + parameters, + cancellationToken: default).ConfigureAwait(false); } catch (Exception ex) { @@ -629,6 +681,44 @@ private static List GetCSharpCodeBlocks(RazorSyntaxTree s return cSharpCodeBlocks; } + private static bool TryMapToClosestGeneratedDocumentRange(RazorCSharpDocument generatedDocument, int start, int end, out Range mappedRange) + { + var sourceMappings = generatedDocument.SourceMappings; + + var closestStartSourceMap = sourceMappings.OrderBy(m => Math.Abs(m.OriginalSpan.AbsoluteIndex - start)).First(); + var closestEndSourceMap = sourceMappings.OrderBy(m => Math.Abs(m.OriginalSpan.AbsoluteIndex - end)).First(); + + var generatedStart = closestStartSourceMap.GeneratedSpan; + var generatedEnd = closestEndSourceMap.GeneratedSpan; + + var generatedStartLinePosition = GetGeneratedPosition(generatedDocument, generatedStart.AbsoluteIndex); + var generatedEndLinePosition = GetGeneratedPosition(generatedDocument, generatedEnd.AbsoluteIndex); + + mappedRange = new Range + { + Start = new Position(generatedStartLinePosition.Line, generatedStartLinePosition.Character), + End = new Position(generatedEndLinePosition.Line, generatedEndLinePosition.Character) + }; + + LinePosition GetGeneratedPosition(IRazorGeneratedDocument generatedDocument, int generatedIndex) + { + var generatedSource = GetGeneratedSourceText(generatedDocument); + return generatedSource.Lines.GetLinePosition(generatedIndex); + } + + SourceText GetGeneratedSourceText(IRazorGeneratedDocument generatedDocument) + { + if (generatedDocument.CodeDocument is not { } codeDocument) + { + throw new InvalidOperationException("Cannot use document mapping service on a generated document that has a null CodeDocument."); + } + + return codeDocument.GetGeneratedSourceText(generatedDocument); + } + + return true; + } + // Get identifiers in code block to union with the identifiers in the extracted code private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd, List previousCodeBlocks) { @@ -667,9 +757,9 @@ private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd return identifiersInLastCodeBlock; } - private static HashSet GetMethodsInContext(RazorComponentInfo componentInfo, IEnumerable methodStringsInContext) + private static HashSet GetMethodsInContext(SymbolicInfo componentInfo, IEnumerable methodStringsInContext) { - var methodsInContext = new HashSet(); + var methodsInContext = new HashSet(); if (componentInfo.Methods is null) { return methodsInContext; @@ -704,7 +794,7 @@ private static HashSet GetMethodsInContext(RazorCo // Create a series of [Parameter] attributes for extracted methods. // Void return functions are promoted to Action delegates. // All other functions should be Func delegates. - private static string GeneratePromotedMethods(HashSet methods) + private static string GeneratePromotedMethods(HashSet methods) { var builder = new StringBuilder(); var parameterCount = 0; @@ -727,11 +817,11 @@ private static string GeneratePromotedMethods(HashSet 0) + if (method.ParameterTypes.Count() > 0) { if (method.ReturnType == "void") { - builder.Append("<"); + builder.Append('<'); } builder.Append(string.Join(", ", method.ParameterTypes)); @@ -746,7 +836,7 @@ private static string GeneratePromotedMethods(HashSet 0 ? ">" : "") : ">")}? " + + builder.Append($"{(method.ReturnType == "void" ? (method.ParameterTypes.Count() > 0 ? ">" : "") : ">")}? " + $"Parameter{(parameterCount > 0 ? parameterCount : "")} {{ get; set; }}"); if (parameterCount < totalMethods - 1) { @@ -779,7 +869,7 @@ private static string GenerateForwardedConstantFields(SyntaxNode codeBlockAtEnd, } // GetFieldsInContext(componentInfo, identifiersInCodeBlock) - private static HashSet GetFieldsInContext(RazorComponentInfo componentInfo, HashSet identifiersInCodeBlock) + private static HashSet GetFieldsInContext(SymbolicInfo componentInfo, HashSet identifiersInCodeBlock) { if (componentInfo.Fields is null) { @@ -803,7 +893,7 @@ private static string GenerateNewFileCodeBlock(string promotedMethods, string ca } // Method invocations in the new file must be replaced with their respective parameter name. This is simply a case of replacing each string. - private static string ReplaceMethodInvocations(string newFileContent, HashSet methods) + private static string ReplaceMethodInvocations(string newFileContent, HashSet methods) { var parameterCount = 0; foreach (var method in methods) @@ -815,7 +905,7 @@ private static string ReplaceMethodInvocations(string newFileContent, HashSet? methods, string componentName) + private static string GenerateComponentNameAndParameters(HashSet? methods, string componentName) { var builder = new StringBuilder(); builder.Append(componentName + " "); @@ -830,7 +920,7 @@ private static string GenerateComponentNameAndParameters(HashSet 0 ? parameterCount : "")}"); builder.Append($"={method.Name}"); - builder.Append(" "); + builder.Append(' '); parameterCount++; } @@ -840,6 +930,6 @@ private static string GenerateComponentNameAndParameters(HashSet? Methods { get; set; } + public required HashSet? Methods { get; set; } } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs similarity index 65% rename from src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs index a257990b22b..cdccd378cee 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/RazorComponentInfoParams.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs @@ -2,14 +2,14 @@ // 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.Collections.Generic; using System.Runtime.Serialization; using System.Text.Json.Serialization; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; -internal sealed record RazorComponentInfoParams +[DataContract] +internal record GetSymbolicInfoParams { [DataMember(Name = "document")] [JsonPropertyName("document")] @@ -30,25 +30,33 @@ internal sealed record RazorComponentInfoParams [DataMember(Name = "newContents")] [JsonPropertyName("newContents")] public required string NewContents { get; set; } + + [DataMember(Name = "mappedRange")] + [JsonPropertyName("mappedRange")] + public required Range MappedRange { get; set; } + + [DataMember(Name = "intersectingSpansInGeneratedRange")] + [JsonPropertyName("intersectingSpansInGeneratedRange")] + + public required Range[] IntersectingSpansInGeneratedMappings { get; set; } } -// Not sure where to put these two records -internal sealed record RazorComponentInfo +internal sealed record SymbolicInfo { - public required List Methods { get; set; } - public required List Fields { get; set; } + public required MethodInRazorInfo[] Methods { get; set; } + public required SymbolInRazorInfo[] Fields { get; set; } } -internal sealed record MethodInsideRazorElementInfo +internal sealed record MethodInRazorInfo { public required string Name { get; set; } public required string ReturnType { get; set; } - public required List ParameterTypes { get; set; } + public required string[] ParameterTypes { get; set; } } -internal sealed record SymbolInsideRazorElementInfo +internal sealed record SymbolInRazorInfo { public required string Name { get; set; } public required string Type { get; set; } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CustomMessageNames.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CustomMessageNames.cs index 860416225ba..7d682a9dab6 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CustomMessageNames.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CustomMessageNames.cs @@ -13,7 +13,7 @@ namespace Microsoft.CodeAnalysis.Razor.Protocol; internal static class CustomMessageNames { // VS Windows only - public const string RazorComponentInfoEndpointName = "razor/razorComponentInfo"; + public const string RazorGetSymbolicInfoEndpointName = "razor/getSymbolicInfo"; public const string RazorInlineCompletionEndpoint = "razor/inlineCompletion"; public const string RazorValidateBreakpointRangeName = "razor/validateBreakpointRange"; public const string RazorOnAutoInsertEndpointName = "razor/onAutoInsert"; diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs index cdd0ce37190..233a3d1ba61 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs @@ -37,7 +37,7 @@ public static class CodeActions public const string ExtractToCodeBehindAction = "ExtractToCodeBehind"; - public const string ExtractToNewComponentAction = "ExtractToNewComponent"; + public const string ExtractToComponentAction = "ExtractToComponent"; public const string CreateComponentFromTag = "CreateComponentFromTag"; diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs similarity index 70% rename from src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs rename to src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs index 05f0a942fc7..f813ef24a2d 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_RazorComponentInfo.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs @@ -13,8 +13,8 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient.Endpoints; internal partial class RazorCustomMessageTarget { - [JsonRpcMethod(CustomMessageNames.RazorComponentInfoEndpointName, UseSingleObjectParameterDeserialization = true)] - public async Task RazorComponentInfoAsync(RazorComponentInfoParams request, CancellationToken cancellationToken) + [JsonRpcMethod(CustomMessageNames.RazorGetSymbolicInfoEndpointName, UseSingleObjectParameterDeserialization = true)] + public async Task RazorGetSymbolicInfoAsync(GetSymbolicInfoParams request, CancellationToken cancellationToken) { var (synchronized, virtualDocumentSnapshot) = await TrySynchronizeVirtualDocumentAsync(request.HostDocumentVersion, request.Document, cancellationToken); if (!synchronized || virtualDocumentSnapshot is null) @@ -23,14 +23,12 @@ internal partial class RazorCustomMessageTarget } request.Document.Uri = virtualDocumentSnapshot.Uri; + ReinvokeResponse response; - ReinvokeResponse response; - - // This endpoint is special because it deals with a file that doesn't exist yet, so there is no document syncing necessary! try { - response = await _requestInvoker.ReinvokeRequestOnServerAsync( - RazorLSPConstants.RoslynRazorComponentInfoEndpointName, + response = await _requestInvoker.ReinvokeRequestOnServerAsync( + RazorLSPConstants.RoslynGetSymbolicInfoEndpointName, RazorLSPConstants.RazorCSharpLanguageServerName, request, cancellationToken).ConfigureAwait(false); diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLSPConstants.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLSPConstants.cs index 4e3875d8a7e..c81a48a2c52 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLSPConstants.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLSPConstants.cs @@ -27,7 +27,7 @@ internal static class RazorLSPConstants public const string RoslynFormatNewFileEndpointName = "roslyn/formatNewFile"; - public const string RoslynRazorComponentInfoEndpointName = "roslyn/razorComponentInfo"; + public const string RoslynGetSymbolicInfoEndpointName = "roslyn/getSymbolicInfo"; public const string RoslynSemanticTokenRangesEndpointName = "roslyn/semanticTokenRanges"; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs index 1b76f097d65..20c9f40eaed 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs @@ -9,7 +9,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Castle.Core.Logging; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Components; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; @@ -17,25 +16,28 @@ using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; -using Microsoft.AspNetCore.Razor.LanguageServer.Test; using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis.ExternalAccess.Razor; using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.Copilot.Internal; using Microsoft.VisualStudio.LanguageServer.Protocol; +using Moq; using Roslyn.Test.Utilities; using Xunit; using Xunit.Abstractions; -using static Microsoft.AspNetCore.Razor.LanguageServer.Formatting.FormattingLanguageServerTestBase; +using Xunit.Sdk; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -64,12 +66,25 @@ private GenerateMethodCodeActionResolver[] CreateRazorCodeActionResolvers( razorFormattingService) ]; - private ExtractToComponentCodeActionResolver[] CreateExtractComponentCodeActionResolver(string filePath, RazorCodeDocument codeDocument) - { + private ExtractToComponentCodeActionResolver[] CreateExtractComponentCodeActionResolver( + string filePath, + RazorCodeDocument codeDocument, + IRazorFormattingService razorFormattingService, + IClientConnection clientConnection, + RazorLSPOptionsMonitor? optionsMonitor = null) + { + var projectManager = new StrictMock(); + int? version = 1; + projectManager.Setup(x => x.TryGetDocumentVersion(It.IsAny(), out version)).Returns(true); + return [ new ExtractToComponentCodeActionResolver( new GenerateMethodResolverDocumentContextFactory(filePath, codeDocument), - TestLanguageServerFeatureOptions.Instance) + optionsMonitor ?? TestRazorLSPOptionsMonitor.Create(), + TestLanguageServerFeatureOptions.Instance, + clientConnection, + razorFormattingService, + projectManager.Object) ]; } @@ -1117,6 +1132,74 @@ await ValidateExtractComponentCodeActionAsync( codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } + [Fact] + public async Task Handle_ExtractComponent_SiblingElement_ReturnsResult() + { + var input = """ + <[|div id="a"> +

Div a title

+ +

Div a par

+ +
+ + + """; + + var expectedRazorComponent = """ +
+

Div a title

+ +

Div a par

+
+
+ +
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_StartNodeContainsEndNode_ReturnsResult() + { + var input = """ + <[|div id="parent"> +
+
+
+

Deeply nested par +

+
+
+
+ """; + + var expectedRazorComponent = """ +
+
+
+
+

Deeply nested par

+
+
+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + #endregion private async Task ValidateCodeBehindFileAsync( @@ -1267,7 +1350,7 @@ private async Task ValidateExtractComponentCodeActionAsync( int childActionIndex = 0, IEnumerable<(string filePath, string contents)>? additionalRazorDocuments = null, IRazorCodeActionProvider[]? razorCodeActionProviders = null, - Func? codeActionResolversCreator = null, + Func? codeActionResolversCreator = null, RazorLSPOptionsMonitor? optionsMonitor = null, Diagnostic[]? diagnostics = null) { @@ -1307,7 +1390,7 @@ private async Task ValidateExtractComponentCodeActionAsync( codeActionToRun, requestContext, languageServer, - codeActionResolversCreator?.Invoke(razorFilePath, codeDocument) ?? []); + codeActionResolversCreator?.Invoke(razorFilePath, codeDocument, formattingService, languageServer, arg5: null) ?? []); var edits = changes.Where(change => change.TextDocument.Uri.AbsolutePath == componentFilePath).Single(); var actual = edits.Edits.Select(edit => edit.NewText).Single(); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs deleted file mode 100644 index a5b84a339ef..00000000000 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ /dev/null @@ -1,419 +0,0 @@ -// 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.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Components; -using Microsoft.AspNetCore.Razor.Language.Extensions; -using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; -using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; -using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; -using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; -using Microsoft.CodeAnalysis.Testing; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.LanguageServer.Protocol; -using Moq; -using Xunit; -using Xunit.Abstractions; -using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.Test.CodeActions.Razor; - -public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) -{ - [Fact] - public async Task Handle_InvalidFileKind() - { - // Arrange - var documentPath = "c:/Test.razor"; - var contents = """ - @page "/" - - Home - -
-
-

Div a title

-

Div $$a par

-
-
-

Div b title

-

Div b par

-
-
- -

Hello, world!

- - Welcome to your new app. - """; - TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); - - var request = new VSCodeActionParams() - { - TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = VsLspFactory.DefaultRange, - Context = new VSInternalCodeActionContext() - }; - - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents); - context.CodeDocument.SetFileKind(FileKinds.Legacy); - - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); - - // Act - var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); - - // Assert - Assert.Empty(commandOrCodeActionContainer); - } - - [Fact] - public async Task Handle_SinglePointSelection_ReturnsNotEmpty() - { - // Arrange - var documentPath = "c:/Test.razor"; - var contents = """ - @page "/" - - Home - -
- <$$div> -

Div a title

-

Div a par

-
-
-

Div b title

-

Div b par

-
- - -

Hello, world!

- - Welcome to your new app. - """; - TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); - - var request = new VSCodeActionParams() - { - TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = VsLspFactory.DefaultRange, - Context = new VSInternalCodeActionContext() - }; - - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents, supportsFileCreation: true); - - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); - - // Act - var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); - - // Assert - Assert.NotEmpty(commandOrCodeActionContainer); - } - - [Fact] - public async Task Handle_CodeInsideDiv_ScansCorrect() - { - // Arrange - var documentPath = "c:/Test.cs"; - var contents = """ - @page "/" - - Home - - <$$div id="codeInside"> - @for(int idx = 0; idx < 10; idx++) { - string s = someFunc(idx * myField); - } - - -
-
-

Div a title

-

Div a par

-
-
-

Div b title

-

Div b par

-
-
- - @code { - public int myField = 7; - - public string someFunc(int num) { - return "Hello for number" + num; - } - } - """; - TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); - - var request = new VSCodeActionParams() - { - TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = new Range(), - Context = new VSInternalCodeActionContext() - }; - - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents); - - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); - - // Act - var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); - - // Assert - Assert.NotEmpty(commandOrCodeActionContainer); - var codeAction = Assert.Single(commandOrCodeActionContainer); - var razorCodeActionResolutionParams = ((JsonElement)codeAction.Data!).Deserialize(); - Assert.NotNull(razorCodeActionResolutionParams); - var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize(); - Assert.NotNull(actionParams); - } - - [Fact] - public async Task Handle_MultiPointSelection_ReturnsNotEmpty() - { - // Arrange - var documentPath = "c:/Test.razor"; - var contents = """ - @page "/" - - Home - -
- [|
- $$

Div a title

-

Div a par

-
-
-

Div b title

-

Div b par

- -
- -

Hello, world!

- - Welcome to your new app. - """; - TestFileMarkupParser.GetPositionAndSpan(contents, out contents, out var cursorPosition, out var selectionSpan); - - var request = new VSCodeActionParams() - { - TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = VsLspFactory.DefaultRange, - Context = new VSInternalCodeActionContext() - }; - - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents); - AddMultiPointSelectionToContext(ref context, selectionSpan); - - var lineSpan = context.SourceText.GetLinePositionSpan(selectionSpan); - request.Range = VsLspFactory.CreateRange(lineSpan); - - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); - - // Act - var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); - - // Assert - Assert.NotEmpty(commandOrCodeActionContainer); - var codeAction = Assert.Single(commandOrCodeActionContainer); - var razorCodeActionResolutionParams = ((JsonElement)codeAction.Data!).Deserialize(); - Assert.NotNull(razorCodeActionResolutionParams); - var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize(); - Assert.NotNull(actionParams); - } - - [Fact] - public async Task Handle_MultiPointSelection_WithEndAfterElement_ReturnsCurrentElement() - { - // Arrange - var documentPath = "c:/Test.razor"; - var contents = """ - @page "/" - @namespace MarketApp.Pages.Product.Home - - namespace MarketApp.Pages.Product.Home - - Home - -
- [|$$
-

Div a title

-

Div a par

-
-
-

Div b title

-

Div b par

-
|] -
- -

Hello, world!

- - Welcome to your new app. - """; - TestFileMarkupParser.GetPositionAndSpan(contents, out contents, out var cursorPosition, out var selectionSpan); - - var request = new VSCodeActionParams() - { - TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = VsLspFactory.DefaultRange, - Context = new VSInternalCodeActionContext() - }; - - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents); - - var lineSpan = context.SourceText.GetLinePositionSpan(selectionSpan); - request.Range = VsLspFactory.CreateRange(lineSpan); - - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); - - // Act - var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); - - // Assert - Assert.NotEmpty(commandOrCodeActionContainer); - var codeAction = Assert.Single(commandOrCodeActionContainer); - var razorCodeActionResolutionParams = ((JsonElement)codeAction.Data!).Deserialize(); - Assert.NotNull(razorCodeActionResolutionParams); - var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize(); - Assert.NotNull(actionParams); - Assert.Equal(selectionSpan.Start, actionParams.ExtractStart); - Assert.Equal(selectionSpan.End, actionParams.ExtractEnd); - } - - [Fact] - public async Task Handle_InProperMarkup_ReturnsEmpty() - { - // Arrange - var documentPath = "c:/Test.razor"; - var contents = """ - @page "/" - - Home - -
-
-

Div a title

-

Div $$a par

-
-
-

Div b title

-

Div b par

-
-
- -

Hello, world!

- - Welcome to your new app. - """; - TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); - - var request = new VSCodeActionParams() - { - TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = new Range(), - Context = new VSInternalCodeActionContext() - }; - - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents); - - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); - - // Act - var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); - - // Assert - Assert.Empty(commandOrCodeActionContainer); - } - - [Fact] - public async Task Handle_InProperMarkup_ReturnsNull() - { - // Arrange - var documentPath = "c:/Test.razor"; - var contents = """ - page "/" - - Home - -
-
-

Div a title

-

Div $$a par

-
-
-

Div b title

-

Div b par

-
-
Hello, world! - - Welcome to your new app. - """; - TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); - - var request = new VSCodeActionParams() - { - TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = new Range(), - Context = new VSInternalCodeActionContext() - }; - - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents); - - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); - - // Act - var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); - - // Assert - Assert.Empty(commandOrCodeActionContainer); - } - - private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, bool supportsFileCreation = true) - => CreateRazorCodeActionContext(request, location, filePath, text, relativePath: filePath, supportsFileCreation: supportsFileCreation); - - private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, string? relativePath, bool supportsFileCreation = true) - { - var sourceDocument = RazorSourceDocument.Create(text, RazorSourceDocumentProperties.Create(filePath, relativePath)); - var options = RazorParserOptions.Create(o => - { - o.Directives.Add(ComponentCodeDirective.Directive); - o.Directives.Add(FunctionsDirective.Directive); - }); - var syntaxTree = RazorSyntaxTree.Parse(sourceDocument, options); - - var codeDocument = TestRazorCodeDocument.Create(sourceDocument, imports: default); - codeDocument.SetFileKind(FileKinds.Component); - codeDocument.SetCodeGenerationOptions(RazorCodeGenerationOptions.Create(o => - { - o.RootNamespace = "ExtractToComponentTest"; - })); - codeDocument.SetSyntaxTree(syntaxTree); - - var documentSnapshot = Mock.Of(document => - document.GetGeneratedOutputAsync() == Task.FromResult(codeDocument) && - document.GetTextAsync() == Task.FromResult(codeDocument.Source.Text), MockBehavior.Strict); - - var sourceText = SourceText.From(text); - - var context = new RazorCodeActionContext(request, documentSnapshot, codeDocument, location, sourceText, supportsFileCreation, SupportsCodeActionResolve: true); - - return context; - } -} From 7795d9ff3d5fc0e0d9e994b04a2dfa255804b207 Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 21 Aug 2024 15:49:22 -0700 Subject: [PATCH 13/29] Refactoring of TryProcessMultiPointSelection and FindContainingSiblingPair --- .../ExtractToComponentCodeActionResolver.cs | 59 +++++++------------ 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index a8e9a35b246..cb72951de6b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -307,19 +307,17 @@ private static bool TryProcessMultiPointSelection(MarkupSyntaxNode startElementN return true; } - // Check if the start element is an ancestor of the end element or vice versa - var startNodeContainsEndNode = endElementNode.Ancestors().Any(node => node == startElementNode); - var endNodeContainsStartNode = startElementNode.Ancestors().Any(node => node == endElementNode); - - // If the start element is an ancestor of the end element (or vice versa), update the extraction - if (endNodeContainsStartNode) + // If the start node contains the end node (or vice versa), we can extract the entire range + if (endElementNode.Ancestors().Contains(startElementNode)) { - extractStart = endElementNode.Span.Start; + extractEnd = startElementNode.Span.End; + return true; } - if (startNodeContainsEndNode) + if (startElementNode.Ancestors().Contains(endElementNode)) { - extractEnd = startElementNode.Span.End; + extractStart = endElementNode.Span.Start; + return true; } // If the start element is not an ancestor of the end element (or vice versa), we need to find a common parent @@ -335,25 +333,19 @@ private static bool TryProcessMultiPointSelection(MarkupSyntaxNode startElementN // Selected text ends here //
// In this case, we need to find the smallest set of complete elements that covers the entire selection. - if (startElementNode != endElementNode && !(startNodeContainsEndNode || endNodeContainsStartNode)) + if (startElementNode != endElementNode) { // Find the closest containing sibling pair that encompasses both the start and end elements var (selectStart, selectEnd) = FindContainingSiblingPair(startElementNode, endElementNode); - - // If we found a valid containing pair, update the extraction range if (selectStart is not null && selectEnd is not null) { extractStart = selectStart.Span.Start; extractEnd = selectEnd.Span.End; - return true; } - // Note: If we don't find a valid pair, we keep the original extraction range - } - if (startElementNode != endElementNode) - { - return true; // Will only trigger when the end of the selection does not include a code block. + // Note: If we don't find a valid pair, we keep the original extraction range + return true; } var endLocation = GetEndLocation(actionParams.SelectEnd, codeDocument.GetSourceText()); @@ -369,18 +361,13 @@ private static bool TryProcessMultiPointSelection(MarkupSyntaxNode startElementN endCodeBlock = previousSibling.FirstAncestorOrSelf(); } - if (endCodeBlock is null) + if (endCodeBlock is not null) { - // One of the cases where this triggers is when a single element is multi-pointedly selected - return true; + var (withCodeBlockStart, withCodeBlockEnd) = FindContainingSiblingPair(startElementNode, endCodeBlock); + extractStart = withCodeBlockStart?.Span.Start ?? extractStart; + extractEnd = withCodeBlockEnd?.Span.End ?? extractEnd; } - var (withCodeBlockStart, withCodeBlockEnd) = FindContainingSiblingPair(startElementNode, endCodeBlock); - - // If selection ends on code block, set the extract end to the end of the code block. - extractStart = withCodeBlockStart?.Span.Start ?? extractStart; - extractEnd = withCodeBlockEnd?.Span.End ?? extractEnd; - return true; } @@ -409,15 +396,16 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy if (startContainingNode is null && childSpan.Contains(startSpan)) { startContainingNode = child; - if (endContainingNode is not null) - break; // Exit if we've found both } if (childSpan.Contains(endSpan)) { endContainingNode = child; - if (startContainingNode is not null) - break; // Exit if we've found both + } + + if (startContainingNode is not null && endContainingNode is not null) + { + break; } } @@ -426,21 +414,18 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy private static SyntaxNode? FindNearestCommonAncestor(SyntaxNode node1, SyntaxNode node2) { - var current = node1; - while (current is not null) + for (var current = node1; current is not null; current = current.Parent) { - if (CheckNode(current) && current.Span.Contains(node2.Span)) + if (IsValidAncestorNode(current) && current.Span.Contains(node2.Span)) { return current; } - - current = current.Parent; } return null; } - private static bool CheckNode(SyntaxNode node) => node is MarkupElementSyntax or MarkupTagHelperElementSyntax or MarkupBlockSyntax; + private static bool IsValidAncestorNode(SyntaxNode node) => node is MarkupElementSyntax or MarkupTagHelperElementSyntax or MarkupBlockSyntax; private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) { From 2b67a3b32919083a0beeffe7efcafa7e7a719317 Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 21 Aug 2024 16:44:25 -0700 Subject: [PATCH 14/29] Change to FindContainingSiblingPair --- .../ExtractToComponentCodeActionResolver.cs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index cb72951de6b..6dae56d997d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -190,7 +190,7 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod endElementNode ??= startElementNode; - var success = TryProcessMultiPointSelection(startElementNode, + var success = TryProcessSelection(startElementNode, endElementNode, codeDocument, actionParams, @@ -268,7 +268,7 @@ private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndEl } // Correct selection to include the current node if the selection ends at the "edge" (i.e. immediately after the ">") of a tag. - if (string.IsNullOrWhiteSpace(endOwner.ToFullString()) && endOwner.TryGetPreviousSibling(out var previousSibling)) + if (endOwner is MarkupTextLiteralSyntax && endOwner.ContainsOnlyWhitespace() && endOwner.TryGetPreviousSibling(out var previousSibling)) { endOwner = previousSibling; } @@ -287,16 +287,16 @@ private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndEl } /// - /// Processes a multi-point selection, providing the start and end of the extraction range if successful. + /// Processes a selection, providing the start and end of the extraction range if successful. /// /// The starting element of the selection. - /// The ending element of the selection, if it exists. + /// The ending element of the selection /// The code document containing the selection. - /// The parameters for the extraction action, which will be updated. + /// The parameters for the extraction action /// The start of the extraction range. /// The end of the extraction range /// true if the selection was successfully processed; otherwise, false. - private static bool TryProcessMultiPointSelection(MarkupSyntaxNode startElementNode, MarkupSyntaxNode endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams, out int extractStart, out int extractEnd) + private static bool TryProcessSelection(MarkupSyntaxNode startElementNode, MarkupSyntaxNode endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams, out int extractStart, out int extractEnd) { extractStart = startElementNode.Span.Start; extractEnd = endElementNode.Span.End; @@ -389,10 +389,14 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy var endIsCodeBlock = endNode is CSharpCodeBlockSyntax; - foreach (var child in nearestCommonAncestor.ChildNodes().Where(node => IsValidNode(node, endIsCodeBlock))) + foreach (var child in nearestCommonAncestor.ChildNodes()) { - var childSpan = child.Span; + if (!IsValidNode(child, endIsCodeBlock)) + { + continue; + } + var childSpan = child.Span; if (startContainingNode is null && childSpan.Contains(startSpan)) { startContainingNode = child; From 426ed28cb084c8c762a005c00b496e3378678cd3 Mon Sep 17 00:00:00 2001 From: marcarro Date: Thu, 22 Aug 2024 12:14:06 -0700 Subject: [PATCH 15/29] Modified forwared constant fields --- .../ExtractToComponentCodeActionResolver.cs | 120 +++++++----------- .../CodeActions/GetSymbolicInfoParams.cs | 20 ++- 2 files changed, 55 insertions(+), 85 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 6dae56d997d..e3c00421380 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -38,6 +38,7 @@ using Microsoft.VisualStudio.Text; using ICSharpCode.Decompiler.CSharp.Syntax; using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using System.Reflection.Metadata.Ecma335; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -324,13 +325,14 @@ private static bool TryProcessSelection(MarkupSyntaxNode startElementNode, Marku // This conditional handles cases where the user's selection spans across different levels of the DOM. // For example: //
- // - // Selected text starts here

Some text

+ // {|result: + // {|selection:

Some text

//
// //

More text

//
- // Selected text ends here + // |}|} + // //
// In this case, we need to find the smallest set of complete elements that covers the entire selection. if (startElementNode != endElementNode) @@ -552,7 +554,6 @@ metadata.Value is not null && } var sourceMappings = razorCodeDocument.GetCSharpDocument().SourceMappings; - var sourceMappingRanges = sourceMappings.Select(m => ( new Range @@ -565,20 +566,17 @@ metadata.Value is not null && var relevantTextSpan = relevantRange.ToTextSpan(razorCodeDocument.Source.Text); var intersectingGeneratedSpans = sourceMappingRanges.Where(m => relevantRange.IntersectsOrTouches(m.Item1)).Select(m => m.GeneratedSpan).ToArray(); + + // I'm not sure why, but for some reason the endCharacterIndex is lower than the CharacterIndex so they must be swapped. var intersectingGeneratedRanges = intersectingGeneratedSpans.Select(m => ( new Range { - Start = new Position(m.LineIndex, m.CharacterIndex), + Start = new Position(m.LineIndex, m.EndCharacterIndex), End = new Position(m.LineIndex, m.CharacterIndex) } )).ToArray(); - if (!TryMapToClosestGeneratedDocumentRange(razorCodeDocument.GetCSharpDocument(), selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd, out var selectionMappedRange)) - { - return result; - } - var parameters = new GetSymbolicInfoParams() { Project = new TextDocumentIdentifier @@ -595,8 +593,7 @@ metadata.Value is not null && }, NewContents = newFileContent, HostDocumentVersion = version.Value, - MappedRange = selectionMappedRange, - IntersectingSpansInGeneratedMappings = intersectingGeneratedRanges + IntersectingRangesInGeneratedMappings = intersectingGeneratedRanges }; SymbolicInfo? componentInfo; @@ -612,7 +609,6 @@ metadata.Value is not null && throw new InvalidOperationException("Failed to send request to RazorComponentInfoEndpoint", ex); } - // Check if client connection call was successful if (componentInfo is null) { return result; @@ -625,7 +621,6 @@ metadata.Value is not null && } var identifiersInCodeBlock = GetIdentifiersInContext(codeBlockAtEnd, cSharpCodeBlocks); - if (componentInfo.Methods is null) { return result; @@ -636,8 +631,9 @@ metadata.Value is not null && var methodsInContext = GetMethodsInContext(componentInfo, methodStringsInContext); var promotedMethods = GeneratePromotedMethods(methodsInContext); - var fieldsInContext = GetFieldsInContext(componentInfo, identifiersInCodeBlock); - var forwardedFields = GenerateForwardedConstantFields(codeBlockAtEnd, fieldsInContext); + var fieldsInContext = GetFieldsInContext(componentInfo.Fields, identifiersInCodeBlock); + var forwardedFields = GenerateForwardedConstantFields(fieldsInContext, Path.GetFileName(razorCodeDocument.Source.FilePath)); + var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, forwardedFields); newFileContent = ReplaceMethodInvocations(newFileContent, methodsInContext); @@ -670,44 +666,6 @@ private static List GetCSharpCodeBlocks(RazorSyntaxTree s return cSharpCodeBlocks; } - private static bool TryMapToClosestGeneratedDocumentRange(RazorCSharpDocument generatedDocument, int start, int end, out Range mappedRange) - { - var sourceMappings = generatedDocument.SourceMappings; - - var closestStartSourceMap = sourceMappings.OrderBy(m => Math.Abs(m.OriginalSpan.AbsoluteIndex - start)).First(); - var closestEndSourceMap = sourceMappings.OrderBy(m => Math.Abs(m.OriginalSpan.AbsoluteIndex - end)).First(); - - var generatedStart = closestStartSourceMap.GeneratedSpan; - var generatedEnd = closestEndSourceMap.GeneratedSpan; - - var generatedStartLinePosition = GetGeneratedPosition(generatedDocument, generatedStart.AbsoluteIndex); - var generatedEndLinePosition = GetGeneratedPosition(generatedDocument, generatedEnd.AbsoluteIndex); - - mappedRange = new Range - { - Start = new Position(generatedStartLinePosition.Line, generatedStartLinePosition.Character), - End = new Position(generatedEndLinePosition.Line, generatedEndLinePosition.Character) - }; - - LinePosition GetGeneratedPosition(IRazorGeneratedDocument generatedDocument, int generatedIndex) - { - var generatedSource = GetGeneratedSourceText(generatedDocument); - return generatedSource.Lines.GetLinePosition(generatedIndex); - } - - SourceText GetGeneratedSourceText(IRazorGeneratedDocument generatedDocument) - { - if (generatedDocument.CodeDocument is not { } codeDocument) - { - throw new InvalidOperationException("Cannot use document mapping service on a generated document that has a null CodeDocument."); - } - - return codeDocument.GetGeneratedSourceText(generatedDocument); - } - - return true; - } - // Get identifiers in code block to union with the identifiers in the extracted code private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd, List previousCodeBlocks) { @@ -746,9 +704,9 @@ private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd return identifiersInLastCodeBlock; } - private static HashSet GetMethodsInContext(SymbolicInfo componentInfo, IEnumerable methodStringsInContext) + private static HashSet GetMethodsInContext(SymbolicInfo componentInfo, IEnumerable methodStringsInContext) { - var methodsInContext = new HashSet(); + var methodsInContext = new HashSet(); if (componentInfo.Methods is null) { return methodsInContext; @@ -783,7 +741,7 @@ private static HashSet GetMethodsInContext(SymbolicInfo compo // Create a series of [Parameter] attributes for extracted methods. // Void return functions are promoted to Action delegates. // All other functions should be Func delegates. - private static string GeneratePromotedMethods(HashSet methods) + private static string GeneratePromotedMethods(HashSet methods) { var builder = new StringBuilder(); var parameterCount = 0; @@ -839,34 +797,48 @@ private static string GeneratePromotedMethods(HashSet methods return builder.ToString(); } - private static string GenerateForwardedConstantFields(SyntaxNode codeBlockAtEnd, HashSet relevantFields) + private static HashSet GetFieldsInContext(FieldSymbolicInfo[] fields, HashSet identifiersInCodeBlock) { - var builder = new StringBuilder(); + if (fields is null) + { + return []; + } - var codeBlockString = codeBlockAtEnd.ToFullString(); + var fieldsInContext = new HashSet(); - var lines = codeBlockString.Split('\n'); - foreach (var line in lines) + foreach (var fieldInfo in fields) { - if (relevantFields.Any(field => line.Contains(field))) + if (identifiersInCodeBlock.Contains(fieldInfo.Name)) { - builder.AppendLine(line.Trim()); + fieldsInContext.Add(fieldInfo); } } - return builder.ToString(); + return fieldsInContext; } - // GetFieldsInContext(componentInfo, identifiersInCodeBlock) - private static HashSet GetFieldsInContext(SymbolicInfo componentInfo, HashSet identifiersInCodeBlock) + private static string GenerateForwardedConstantFields(HashSet relevantFields, string? sourceDocumentFileName) { - if (componentInfo.Fields is null) + var builder = new StringBuilder(); + var fieldCount = 0; + var totalFields = relevantFields.Count; + + foreach (var field in relevantFields) { - return []; + if (field.IsValueType || field.Type == "string") + { + builder.AppendLine($"// Warning: Field '{field.Name}' was passed by value and may not be referenced correctly. Please check its usage in the original document: '{sourceDocumentFileName}'."); + } + + builder.AppendLine($"public {field.Type} {field.Name}"); + + if (fieldCount < totalFields - 1) + { + builder.AppendLine(); + } } - var identifiersInFile = componentInfo.Fields.Select(field => field.Name).ToHashSet(); - return identifiersInFile.Intersect(identifiersInCodeBlock).ToHashSet(); + return builder.ToString(); } private static string GenerateNewFileCodeBlock(string promotedMethods, string carryoverFields) @@ -882,7 +854,7 @@ private static string GenerateNewFileCodeBlock(string promotedMethods, string ca } // Method invocations in the new file must be replaced with their respective parameter name. This is simply a case of replacing each string. - private static string ReplaceMethodInvocations(string newFileContent, HashSet methods) + private static string ReplaceMethodInvocations(string newFileContent, HashSet methods) { var parameterCount = 0; foreach (var method in methods) @@ -894,7 +866,7 @@ private static string ReplaceMethodInvocations(string newFileContent, HashSet? methods, string componentName) + private static string GenerateComponentNameAndParameters(HashSet? methods, string componentName) { var builder = new StringBuilder(); builder.Append(componentName + " "); @@ -919,6 +891,6 @@ private static string GenerateComponentNameAndParameters(HashSet? Methods { get; set; } + public required HashSet? Methods { get; set; } } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs index cdccd378cee..e7ee5178c0c 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs @@ -31,23 +31,19 @@ internal record GetSymbolicInfoParams [JsonPropertyName("newContents")] public required string NewContents { get; set; } - [DataMember(Name = "mappedRange")] - [JsonPropertyName("mappedRange")] - public required Range MappedRange { get; set; } + [DataMember(Name = "intersectingRangesInGeneratedMappings")] + [JsonPropertyName("intersectingRangesInGeneratedMappings")] - [DataMember(Name = "intersectingSpansInGeneratedRange")] - [JsonPropertyName("intersectingSpansInGeneratedRange")] - - public required Range[] IntersectingSpansInGeneratedMappings { get; set; } + public required Range[] IntersectingRangesInGeneratedMappings { get; set; } } internal sealed record SymbolicInfo { - public required MethodInRazorInfo[] Methods { get; set; } - public required SymbolInRazorInfo[] Fields { get; set; } + public required MethodSymbolicInfo[] Methods { get; set; } + public required FieldSymbolicInfo[] Fields { get; set; } } -internal sealed record MethodInRazorInfo +internal sealed record MethodSymbolicInfo { public required string Name { get; set; } @@ -56,8 +52,10 @@ internal sealed record MethodInRazorInfo public required string[] ParameterTypes { get; set; } } -internal sealed record SymbolInRazorInfo +internal sealed record FieldSymbolicInfo { public required string Name { get; set; } public required string Type { get; set; } + public required bool IsValueType { get; set; } + public required bool IsWrittenTo { get; set; } } From b9492f2b59cd70016fd62eef4ab095288b965a8a Mon Sep 17 00:00:00 2001 From: marcarro Date: Sun, 25 Aug 2024 12:30:45 -0700 Subject: [PATCH 16/29] Preemptively added suggestions from previous PR feedback --- .../ExtractToComponentCodeActionResolver.cs | 92 +++++++++---------- .../CodeActions/GetSymbolicInfoParams.cs | 8 -- 2 files changed, 44 insertions(+), 56 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index e3c00421380..f1bcb6898ee 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -39,6 +39,7 @@ using ICSharpCode.Decompiler.CSharp.Syntax; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using System.Reflection.Metadata.Ecma335; +using Microsoft.VisualStudio.Utilities; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -118,7 +119,7 @@ internal sealed class ExtractToComponentCodeActionResolver( }.Uri; var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, newComponentUri, documentContext, removeRange, cancellationToken).ConfigureAwait(false); + var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, documentContext, removeRange, cancellationToken).ConfigureAwait(false); if (newComponentResult is null) { @@ -165,6 +166,7 @@ internal sealed class ExtractToComponentCodeActionResolver( DocumentChanges = documentChanges, }; } + private static ExtractToComponentCodeActionParams? DeserializeActionParams(JsonElement data) { return data.ValueKind == JsonValueKind.Undefined @@ -446,13 +448,9 @@ private static HashSet AddComponentDependenciesInRange(SyntaxNode root, // Only analyze nodes within the extract span foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) { - if (node is MarkupTagHelperElementSyntax) + if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) { - var tagHelperInfo = GetTagHelperInfo(node); - if (tagHelperInfo is not null) - { - AddDependenciesFromTagHelperInfo(tagHelperInfo, ref dependencies); - } + AddDependenciesFromTagHelperInfo(tagHelperInfo, dependencies); } } @@ -481,32 +479,17 @@ private static HashSet AddVariableDependenciesInRange(SyntaxNode root, i return dependencies; } - private static TagHelperInfo? GetTagHelperInfo(SyntaxNode node) - { - if (node is MarkupTagHelperElementSyntax markupElement) - { - return markupElement.TagHelperInfo; - } - - return null; - } - - private static void AddDependenciesFromTagHelperInfo(TagHelperInfo tagHelperInfo, ref HashSet dependencies) + private static void AddDependenciesFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet dependencies) { foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) { - if (descriptor is not null) + if (descriptor is null) { - foreach (var metadata in descriptor.Metadata) - { - if (metadata.Key == TagHelperMetadata.Common.TypeNamespace && - metadata.Value is not null && - !dependencies.Contains($"@using {metadata.Value}")) - { - dependencies.Add($"@using {metadata.Value}"); - } - } + continue; } + + var typeNamespace = descriptor.GetTypeNamespace(); + dependencies.Add(typeNamespace); } } @@ -514,7 +497,6 @@ metadata.Value is not null && SelectionAnalysisResult selectionAnalysis, RazorCodeDocument razorCodeDocument, Uri componentUri, - Uri newComponentUri, DocumentContext documentContext, Range relevantRange, CancellationToken cancellationToken) @@ -525,12 +507,27 @@ metadata.Value is not null && return null; } - var dependencies = selectionAnalysis.ComponentDependencies is not null - ? string.Join(Environment.NewLine, selectionAnalysis.ComponentDependencies) - : string.Empty; + var inst = PooledStringBuilder.GetInstance(); + var newFileContentBuilder = inst.Builder; + if (selectionAnalysis.ComponentDependencies is not null) + { + foreach (var dependency in selectionAnalysis.ComponentDependencies) + { + newFileContentBuilder.AppendLine($"@using {dependency}"); + } - var extractedContents = contents.GetSubTextString(new CodeAnalysis.Text.TextSpan(selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)).Trim(); - var newFileContent = $"{dependencies}{(dependencies.Length > 0 ? Environment.NewLine + Environment.NewLine : "")}{extractedContents}"; + if (newFileContentBuilder.Length > 0) + { + newFileContentBuilder.AppendLine(); + } + } + + var extractedContents = contents.GetSubTextString( + new TextSpan(selectionAnalysis.ExtractStart, + selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)) + .Trim(); + + newFileContentBuilder.Append(extractedContents); // Get CSharpStatements within component var syntaxTree = razorCodeDocument.GetSyntaxTree(); @@ -538,18 +535,20 @@ metadata.Value is not null && var result = new NewRazorComponentInfo { - NewContents = newFileContent, + NewContents = newFileContentBuilder.ToString(), Methods = [] }; // Only make the Roslyn call if there is valid CSharp in the selected code. if (cSharpCodeBlocks.Count == 0) { + inst.Free(); return result; } if (!_documentVersionCache.TryGetDocumentVersion(documentContext.Snapshot, out var version)) { + inst.Free(); return result; } @@ -587,11 +586,6 @@ metadata.Value is not null && { Uri = componentUri }, - NewDocument = new TextDocumentIdentifier - { - Uri = newComponentUri - }, - NewContents = newFileContent, HostDocumentVersion = version.Value, IntersectingRangesInGeneratedMappings = intersectingGeneratedRanges }; @@ -611,18 +605,21 @@ metadata.Value is not null && if (componentInfo is null) { + inst.Free(); return result; } var codeBlockAtEnd = GetCodeBlockAtEnd(syntaxTree); if (codeBlockAtEnd is null) { + inst.Free(); return result; } var identifiersInCodeBlock = GetIdentifiersInContext(codeBlockAtEnd, cSharpCodeBlocks); if (componentInfo.Methods is null) { + inst.Free(); return result; } @@ -636,12 +633,13 @@ metadata.Value is not null && var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, forwardedFields); - newFileContent = ReplaceMethodInvocations(newFileContent, methodsInContext); - newFileContent += newFileCodeBlock; + ReplaceMethodInvocations(newFileContentBuilder, methodsInContext); + newFileContentBuilder.Append(newFileCodeBlock); - result.NewContents = newFileContent; + result.NewContents = newFileContentBuilder.ToString(); result.Methods = methodsInContext; + inst.Free(); return result; } @@ -853,17 +851,15 @@ private static string GenerateNewFileCodeBlock(string promotedMethods, string ca return builder.ToString(); } - // Method invocations in the new file must be replaced with their respective parameter name. This is simply a case of replacing each string. - private static string ReplaceMethodInvocations(string newFileContent, HashSet methods) + // Method invocations in the new file must be replaced with their respective parameter name. + private static void ReplaceMethodInvocations(StringBuilder newFileContentBuilder, HashSet methods) { var parameterCount = 0; foreach (var method in methods) { - newFileContent = newFileContent.Replace(method.Name, $"Parameter{(parameterCount > 0 ? parameterCount : "")}"); + newFileContentBuilder.Replace(method.Name, $"Parameter{(parameterCount > 0 ? parameterCount : "")}"); parameterCount++; } - - return newFileContent; } private static string GenerateComponentNameAndParameters(HashSet? methods, string componentName) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs index e7ee5178c0c..30c36b52cc9 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs @@ -15,10 +15,6 @@ internal record GetSymbolicInfoParams [JsonPropertyName("document")] public required TextDocumentIdentifier Document { get; set; } - [DataMember(Name = "newDocument")] - [JsonPropertyName("newDocument")] - public required TextDocumentIdentifier NewDocument { get; set; } - [DataMember(Name = "project")] [JsonPropertyName("project")] public required TextDocumentIdentifier Project { get; set; } @@ -27,10 +23,6 @@ internal record GetSymbolicInfoParams [JsonPropertyName("hostDocumentVersion")] public required int HostDocumentVersion { get; set; } - [DataMember(Name = "newContents")] - [JsonPropertyName("newContents")] - public required string NewContents { get; set; } - [DataMember(Name = "intersectingRangesInGeneratedMappings")] [JsonPropertyName("intersectingRangesInGeneratedMappings")] From 462062a32148bad802243f4f5953715827167503 Mon Sep 17 00:00:00 2001 From: marcarro Date: Sun, 25 Aug 2024 16:59:15 -0700 Subject: [PATCH 17/29] Naming fixes and simplified promoted method string generation --- .../ExtractToComponentCodeActionProvider.cs | 10 +++- .../ExtractToComponentCodeActionResolver.cs | 58 +++++++++---------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index 92f182b61ef..b737565a83d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -32,13 +32,13 @@ public Task> ProvideAsync(RazorCodeAct return SpecializedTasks.EmptyImmutableArray(); } - var syntaxTree = context.CodeDocument.GetSyntaxTree(); - if (!IsSelectionValid(context, syntaxTree)) + if (!TryGetNamespace(context.CodeDocument, out var @namespace)) { return SpecializedTasks.EmptyImmutableArray(); } - if (!TryGetNamespace(context.CodeDocument, out var @namespace)) + var syntaxTree = context.CodeDocument.GetSyntaxTree(); + if (!IsSelectionValid(context, syntaxTree)) { return SpecializedTasks.EmptyImmutableArray(); } @@ -94,6 +94,10 @@ private static bool IsSelectionValid(RazorCodeActionContext context, RazorSyntax private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, SyntaxNode owner) { + // The selection could start either in a MarkupElement or MarkupTagHelperElement. + // Both of these have the necessary properties to do this check, but not the base MarkupSyntaxNode. + // The workaround for this is to try to cast to the specific types and then do the check. + var tryMakeMarkupElement = owner.FirstAncestorOrSelf(); var tryMakeMarkupTagHelperElement = owner.FirstAncestorOrSelf(); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index f1bcb6898ee..700c98d795e 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -179,7 +179,7 @@ internal sealed record SelectionAnalysisResult public required bool Success; public int ExtractStart; public int ExtractEnd; - public HashSet? ComponentDependencies; + public HashSet? UsingDirectives; public HashSet? TentativeVariableDependencies; } @@ -206,7 +206,7 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod } var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - var componentDependencies = AddComponentDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); + var componentDependencies = AddUsingDirectivesInRange(dependencyScanRoot, extractStart, extractEnd); var variableDependencies = AddVariableDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); return new SelectionAnalysisResult @@ -214,7 +214,7 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod Success = success, ExtractStart = extractStart, ExtractEnd = extractEnd, - ComponentDependencies = componentDependencies, + UsingDirectives = componentDependencies, TentativeVariableDependencies = variableDependencies, }; } @@ -440,9 +440,9 @@ private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) return node is MarkupElementSyntax or MarkupTagHelperElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); } - private static HashSet AddComponentDependenciesInRange(SyntaxNode root, int extractStart, int extractEnd) + private static HashSet AddUsingDirectivesInRange(SyntaxNode root, int extractStart, int extractEnd) { - var dependencies = new HashSet(); + var usings = new HashSet(); var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); // Only analyze nodes within the extract span @@ -450,11 +450,11 @@ private static HashSet AddComponentDependenciesInRange(SyntaxNode root, { if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) { - AddDependenciesFromTagHelperInfo(tagHelperInfo, dependencies); + AddUsingFromTagHelperInfo(tagHelperInfo, usings); } } - return dependencies; + return usings; } private static HashSet AddVariableDependenciesInRange(SyntaxNode root, int extractStart, int extractEnd) @@ -479,7 +479,7 @@ private static HashSet AddVariableDependenciesInRange(SyntaxNode root, i return dependencies; } - private static void AddDependenciesFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet dependencies) + private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet dependencies) { foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) { @@ -509,9 +509,9 @@ private static void AddDependenciesFromTagHelperInfo(TagHelperInfo tagHelperInfo var inst = PooledStringBuilder.GetInstance(); var newFileContentBuilder = inst.Builder; - if (selectionAnalysis.ComponentDependencies is not null) + if (selectionAnalysis.UsingDirectives is not null) { - foreach (var dependency in selectionAnalysis.ComponentDependencies) + foreach (var dependency in selectionAnalysis.UsingDirectives) { newFileContentBuilder.AppendLine($"@using {dependency}"); } @@ -751,40 +751,34 @@ private static string GeneratePromotedMethods(HashSet method builder.AppendLine($"/// Delegate for the '{method.Name}' method."); builder.AppendLine("/// "); builder.AppendLine("[Parameter]"); - builder.Append("public "); - if (method.ReturnType == "void") - { - builder.Append("Action"); - } - else - { - builder.Append("Func<"); - } + // Start building delegate type + builder.Append("public "); + builder.Append(method.ReturnType == "void" ? "Action" : "Func"); - if (method.ParameterTypes.Count() > 0) + // If delegate type is Action, only add generic parameters if needed. + if (method.ParameterTypes.Length > 0 || method.ReturnType != "void") { - if (method.ReturnType == "void") - { - builder.Append('<'); - } - + builder.Append("<"); builder.Append(string.Join(", ", method.ParameterTypes)); + if (method.ReturnType != "void") { - builder.Append(", "); + if (method.ParameterTypes.Length > 0) + { + // Add one last comma in the list of generic parameters for the result: "<..., TResult>" + builder.Append(", "); + } + builder.Append(method.ReturnType); } - } - if (method.ReturnType != "void") - { - builder.Append(method.ReturnType); + builder.Append('>'); } - builder.Append($"{(method.ReturnType == "void" ? (method.ParameterTypes.Count() > 0 ? ">" : "") : ">")}? " + - $"Parameter{(parameterCount > 0 ? parameterCount : "")} {{ get; set; }}"); + builder.Append($"Parameter{(parameterCount > 0 ? parameterCount : "")} {{ get; set; }}"); if (parameterCount < totalMethods - 1) { + // Space between methods except for the last method. builder.AppendLine(); builder.AppendLine(); } From 96221e590409882a88bbc903bc5fe64a8c48a478 Mon Sep 17 00:00:00 2001 From: marcarro Date: Tue, 27 Aug 2024 11:01:16 -0700 Subject: [PATCH 18/29] Adjust indentation for component extraction and some more tests --- .../ExtractToComponentCodeActionResolver.cs | 29 +++- .../CodeActionEndToEndTest.NetFx.cs | 142 ++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 700c98d795e..47dfa5d76b6 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -93,6 +93,17 @@ internal sealed class ExtractToComponentCodeActionResolver( return null; } + // For the purposes of determining the indentation of the extracted code, get the whitespace before the start of the selection. + var whitespaceReferenceOwner = codeDocument.GetSyntaxTree().Root.FindInnermostNode(selectionAnalysis.ExtractStart, includeWhitespace: true).AssumeNotNull(); + var whitespaceReferenceNode = whitespaceReferenceOwner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); + var whitespace = string.Empty; + if (whitespaceReferenceNode.TryGetPreviousSibling(out var startPreviousSibling) && startPreviousSibling.ContainsOnlyWhitespace()) + { + // Get the whitespace substring so we know how much to dedent the extracted code. Remove any carriage return and newline escape characters. + whitespace = startPreviousSibling.ToFullString(); + whitespace = whitespace.Replace("\r", string.Empty).Replace("\n", string.Empty); + } + var start = codeDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractStart); var end = codeDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractEnd); var removeRange = new Range @@ -119,7 +130,7 @@ internal sealed class ExtractToComponentCodeActionResolver( }.Uri; var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, documentContext, removeRange, cancellationToken).ConfigureAwait(false); + var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, documentContext, removeRange, newComponentUri, whitespace, cancellationToken).ConfigureAwait(false); if (newComponentResult is null) { @@ -499,6 +510,8 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS Uri componentUri, DocumentContext documentContext, Range relevantRange, + Uri newComponentUri, + string whitespace, CancellationToken cancellationToken) { var contents = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); @@ -527,6 +540,18 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)) .Trim(); + // Go through each line of the extractedContents and remove the whitespace from the beginning of each line. + var extractedLines = extractedContents.Split('\n'); + for (var i = 1; i < extractedLines.Length; i++) + { + var line = extractedLines[i]; + if (line.StartsWith(whitespace, StringComparison.Ordinal)) + { + extractedLines[i] = line.Substring(whitespace.Length); + } + } + + extractedContents = string.Join("\n", extractedLines); newFileContentBuilder.Append(extractedContents); // Get CSharpStatements within component @@ -809,6 +834,8 @@ private static HashSet GetFieldsInContext(FieldSymbolicInfo[] return fieldsInContext; } + // By forwarded fields, I mean fields that are present in the extraction, but get directly added/copied to the extracted component's code block, instead of being passed as an attribute. + // If you have naming suggestions that make more sense, please let me know. private static string GenerateForwardedConstantFields(HashSet relevantFields, string? sourceDocumentFileName) { var builder = new StringBuilder(); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs index 20c9f40eaed..34bc3ad6cd0 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs @@ -1200,6 +1200,148 @@ await ValidateExtractComponentCodeActionAsync( codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } + [Fact] + public async Task Handle_ExtractComponent_IndentedNode_ReturnsResult() + { + var input = """ +
+
+ <[||]div> +
+

Deeply nested par

+
+
+
+ + """; + + var expectedRazorComponent = """ +
+
+

Deeply nested par

+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_IndentedSiblingNodes_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|div> +

Deeply nested par

+
+
+

Deeply nested par

+
+
+

Deeply nested par

+
+
+

Deeply nested par

+ +
+
+
+
+ """; + + var expectedRazorComponent = """ +
+

Deeply nested par

+
+
+

Deeply nested par

+
+
+

Deeply nested par

+
+
+

Deeply nested par

+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_IndentedStartNodeContainsEndNode_ReturnsResult() + { + var input = """ +
+
+ <[|div> +
+

Deeply nested par +

+
+
+ + """; + + var expectedRazorComponent = """ +
+
+

Deeply nested par

+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_IndentedEndNodeContainsStartNode_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|p>Deeply nested par

+
+ +
+
+ """; + + var expectedRazorComponent = """ +
+
+

Deeply nested par

+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + #endregion private async Task ValidateCodeBehindFileAsync( From 0f547f36467c93e20feb480795f465a4e545dd66 Mon Sep 17 00:00:00 2001 From: marcarro Date: Tue, 27 Aug 2024 11:46:44 -0700 Subject: [PATCH 19/29] Removed unnecessary usings and separated end to end tests --- .../ExtractToComponentCodeActionProvider.cs | 3 +- .../ExtractToComponentCodeActionResolver.cs | 19 +- ...onEndToEndTest.ExtractToComponent.NetFx.cs | 455 +++++++++++++++++ .../CodeActionEndToEndTest.NetFx.cs | 461 +----------------- 4 files changed, 464 insertions(+), 474 deletions(-) create mode 100644 src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index b737565a83d..f5639de9571 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -80,11 +80,12 @@ private static bool IsValidContext(RazorCodeActionContext context) context.CodeDocument.GetSyntaxTree()?.Root is not null; } - private static bool IsSelectionValid(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) + private bool IsSelectionValid(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) { var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); if (owner is null) { + _logger.LogWarning($"Owner should never be null."); return false; } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 47dfa5d76b6..6843547b9d7 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -46,19 +46,13 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; internal sealed class ExtractToComponentCodeActionResolver( IDocumentContextFactory documentContextFactory, - RazorLSPOptionsMonitor razorLSPOptionsMonitor, LanguageServerFeatureOptions languageServerFeatureOptions, IClientConnection clientConnection, - IRazorFormattingService razorFormattingService, IDocumentVersionCache documentVersionCache) : IRazorCodeActionResolver { - private static readonly Workspace s_workspace = new AdhocWorkspace(); - private readonly IDocumentContextFactory _documentContextFactory = documentContextFactory; - private readonly RazorLSPOptionsMonitor _razorLSPOptionsMonitor = razorLSPOptionsMonitor; private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions; private readonly IClientConnection _clientConnection = clientConnection; - private readonly IRazorFormattingService _razorFormattingService = razorFormattingService; private readonly IDocumentVersionCache _documentVersionCache = documentVersionCache; public string Action => LanguageServerConstants.CodeActions.ExtractToComponentAction; @@ -95,7 +89,7 @@ internal sealed class ExtractToComponentCodeActionResolver( // For the purposes of determining the indentation of the extracted code, get the whitespace before the start of the selection. var whitespaceReferenceOwner = codeDocument.GetSyntaxTree().Root.FindInnermostNode(selectionAnalysis.ExtractStart, includeWhitespace: true).AssumeNotNull(); - var whitespaceReferenceNode = whitespaceReferenceOwner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); + var whitespaceReferenceNode = whitespaceReferenceOwner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax).AssumeNotNull(); var whitespace = string.Empty; if (whitespaceReferenceNode.TryGetPreviousSibling(out var startPreviousSibling) && startPreviousSibling.ContainsOnlyWhitespace()) { @@ -130,7 +124,7 @@ internal sealed class ExtractToComponentCodeActionResolver( }.Uri; var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, documentContext, removeRange, newComponentUri, whitespace, cancellationToken).ConfigureAwait(false); + var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, documentContext, removeRange, whitespace, cancellationToken).ConfigureAwait(false); if (newComponentResult is null) { @@ -510,7 +504,6 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS Uri componentUri, DocumentContext documentContext, Range relevantRange, - Uri newComponentUri, string whitespace, CancellationToken cancellationToken) { @@ -547,7 +540,7 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS var line = extractedLines[i]; if (line.StartsWith(whitespace, StringComparison.Ordinal)) { - extractedLines[i] = line.Substring(whitespace.Length); + extractedLines[i] = line[whitespace.Length..]; } } @@ -593,13 +586,12 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS // I'm not sure why, but for some reason the endCharacterIndex is lower than the CharacterIndex so they must be swapped. var intersectingGeneratedRanges = intersectingGeneratedSpans.Select(m => - ( new Range { Start = new Position(m.LineIndex, m.EndCharacterIndex), End = new Position(m.LineIndex, m.CharacterIndex) } - )).ToArray(); + ).ToArray(); var parameters = new GetSymbolicInfoParams() { @@ -784,7 +776,7 @@ private static string GeneratePromotedMethods(HashSet method // If delegate type is Action, only add generic parameters if needed. if (method.ParameterTypes.Length > 0 || method.ReturnType != "void") { - builder.Append("<"); + builder.Append('<'); builder.Append(string.Join(", ", method.ParameterTypes)); if (method.ReturnType != "void") @@ -794,6 +786,7 @@ private static string GeneratePromotedMethods(HashSet method // Add one last comma in the list of generic parameters for the result: "<..., TResult>" builder.Append(", "); } + builder.Append(method.ReturnType); } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs new file mode 100644 index 00000000000..938349e20b7 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs @@ -0,0 +1,455 @@ +// 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.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Components; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; +using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; +using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; +using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; +using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; +using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; +using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.Copilot.Internal; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Moq; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; + +public partial class CodeActionEndToEndTest : SingleServerDelegatingEndpointTestBase +{ + private ExtractToComponentCodeActionResolver[] CreateExtractComponentCodeActionResolver( + string filePath, + RazorCodeDocument codeDocument, + IClientConnection clientConnection) + { + var projectManager = new StrictMock(); + int? version = 1; + projectManager.Setup(x => x.TryGetDocumentVersion(It.IsAny(), out version)).Returns(true); + + return [ + new ExtractToComponentCodeActionResolver( + new GenerateMethodResolverDocumentContextFactory(filePath, codeDocument), // We can use the same factory here + TestLanguageServerFeatureOptions.Instance, + clientConnection, + projectManager.Object) + ]; + } + + [Fact] + public async Task Handle_ExtractComponent_SingleElement_ReturnsResult() + { + var input = """ + <[||]div id="a"> +

Div a title

+ +

Div a par

+
+
+ +
+ """; + + var expectedRazorComponent = """ +
+

Div a title

+ +

Div a par

+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_SiblingElement_ReturnsResult() + { + var input = """ + <[|div id="a"> +

Div a title

+ +

Div a par

+ +
+ + + """; + + var expectedRazorComponent = """ +
+

Div a title

+ +

Div a par

+
+
+ +
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_StartNodeContainsEndNode_ReturnsResult() + { + var input = """ + <[|div id="parent"> +
+
+
+

Deeply nested par +

+
+
+
+ """; + + var expectedRazorComponent = """ +
+
+
+
+

Deeply nested par

+
+
+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_EndNodeContainsStartNode_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|p>Deeply nested par

+
+
+
+ + """; + + var expectedRazorComponent = """ +
+
+
+
+

Deeply nested par

+
+
+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_IndentedNode_ReturnsResult() + { + var input = """ +
+
+ <[||]div> +
+

Deeply nested par

+
+
+
+
+ """; + + var expectedRazorComponent = """ +
+
+

Deeply nested par

+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_IndentedSiblingNodes_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|div> +

Deeply nested par

+
+
+

Deeply nested par

+
+
+

Deeply nested par

+
+
+

Deeply nested par

+ +
+
+
+
+ """; + + var expectedRazorComponent = """ +
+

Deeply nested par

+
+
+

Deeply nested par

+
+
+

Deeply nested par

+
+
+

Deeply nested par

+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_IndentedStartNodeContainsEndNode_ReturnsResult() + { + var input = """ +
+
+ <[|div> +
+

Deeply nested par +

+
+
+ + """; + + var expectedRazorComponent = """ +
+
+

Deeply nested par

+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_IndentedEndNodeContainsStartNode_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|p>Deeply nested par

+
+ +
+
+ """; + + var expectedRazorComponent = """ +
+
+

Deeply nested par

+
+
+ """; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + + [Fact (Skip = "Need to figure out how to use additionalRazorFiles")] + public async Task Handle_ExtractComponent_MarkupWithUsings_ReturnsResult() + { + var input = """ +
+ +
+ """; + + var book = """ + @namespace BlazorApp1.Shared + +

Title: @Title

+

Written by @Author in @Year

+ + + @code { + [Parameter] + public string Title { get; set; } + + [Parameter] + public string Author { get; set; } + + [Parameter] + public string Year { get; set; } + } + """; + + var expectedRazorComponent = """ +
+ +
+ """; + + var additionalRazorDocuments = new[] + { + ("Book.razor", book) + }; + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + additionalRazorDocuments: additionalRazorDocuments, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + private async Task ValidateExtractComponentCodeActionAsync( + string input, + string? expected, + string codeAction, + int childActionIndex = 0, + IEnumerable<(string filePath, string contents)>? additionalRazorDocuments = null, + IRazorCodeActionProvider[]? razorCodeActionProviders = null, + Func? codeActionResolversCreator = null, + Diagnostic[]? diagnostics = null) + { + TestFileMarkupParser.GetSpan(input, out input, out var textSpan); + + var razorFilePath = "C:/path/Test.razor"; + var componentFilePath = "C:/path/Component.razor"; + var codeDocument = CreateCodeDocument(input, filePath: razorFilePath); + var sourceText = codeDocument.GetSourceText(); + var uri = new Uri(razorFilePath); + var languageServer = await CreateLanguageServerAsync(codeDocument, razorFilePath, additionalRazorDocuments); + + //var projectManager = CreateProjectSnapshotManager(); + + //await projectManager.UpdateAsync(updater => + //{ + // updater.ProjectAdded(new( + // projectFilePath: "C:/path/to/project.csproj", + // intermediateOutputPath: "C:/path/to/obj", + // razorConfiguration: RazorConfiguration.Default, + // rootNamespace: "project")); + //}); + + //var componentSearchEngine = new DefaultRazorComponentSearchEngine(projectManager, LoggerFactory); + //var componentDefinitionService = new RazorComponentDe + + var documentContext = CreateDocumentContext(uri, codeDocument); + var requestContext = new RazorRequestContext(documentContext, null!, "lsp/method", uri: null); + + var result = await GetCodeActionsAsync( + uri, + textSpan, + sourceText, + requestContext, + languageServer, + razorCodeActionProviders, + diagnostics); + + Assert.NotEmpty(result); + var codeActionToRun = GetCodeActionToRun(codeAction, childActionIndex, result); + + if (expected is null) + { + Assert.Null(codeActionToRun); + return; + } + + Assert.NotNull(codeActionToRun); + + var changes = await GetEditsAsync( + codeActionToRun, + requestContext, + languageServer, + codeActionResolversCreator?.Invoke(razorFilePath, codeDocument, languageServer) ?? []); + + var edits = changes.Where(change => change.TextDocument.Uri.AbsolutePath == componentFilePath).Single(); + var actual = edits.Edits.Select(edit => edit.NewText).Single(); + + AssertEx.EqualOrDiff(expected, actual); + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs index 34bc3ad6cd0..c602537e760 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs @@ -41,7 +41,7 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; -public class CodeActionEndToEndTest(ITestOutputHelper testOutput) : SingleServerDelegatingEndpointTestBase(testOutput) +public partial class CodeActionEndToEndTest(ITestOutputHelper testOutput) : SingleServerDelegatingEndpointTestBase(testOutput) { private const string GenerateEventHandlerTitle = "Generate Event Handler 'DoesNotExist'"; private const string ExtractToComponentTitle = "Extract element to new component"; @@ -66,28 +66,6 @@ private GenerateMethodCodeActionResolver[] CreateRazorCodeActionResolvers( razorFormattingService) ]; - private ExtractToComponentCodeActionResolver[] CreateExtractComponentCodeActionResolver( - string filePath, - RazorCodeDocument codeDocument, - IRazorFormattingService razorFormattingService, - IClientConnection clientConnection, - RazorLSPOptionsMonitor? optionsMonitor = null) - { - var projectManager = new StrictMock(); - int? version = 1; - projectManager.Setup(x => x.TryGetDocumentVersion(It.IsAny(), out version)).Returns(true); - - return [ - new ExtractToComponentCodeActionResolver( - new GenerateMethodResolverDocumentContextFactory(filePath, codeDocument), - optionsMonitor ?? TestRazorLSPOptionsMonitor.Create(), - TestLanguageServerFeatureOptions.Instance, - clientConnection, - razorFormattingService, - projectManager.Object) - ]; - } - #region CSharp CodeAction Tests [Fact] @@ -1034,314 +1012,6 @@ await ValidateCodeActionAsync(input, diagnostics: [new Diagnostic() { Code = "CS0103", Message = "The name 'DoesNotExist' does not exist in the current context" }]); } - [Fact] - public async Task Handle_ExtractComponent_SingleElement_ReturnsResult() - { - var input = """ - <[||]div id="a"> -

Div a title

- -

Div a par

-
-
- -
- """; - - var expectedRazorComponent = """ -
-

Div a title

- -

Div a par

-
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - - [Fact] - public async Task Handle_ExtractComponent_SiblingElement_ReturnsResult() - { - var input = """ - <[|div id="a"> -

Div a title

- -

Div a par

- -
- - - """; - - var expectedRazorComponent = """ -
-

Div a title

- -

Div a par

-
-
- -
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - - [Fact] - public async Task Handle_ExtractComponent_StartNodeContainsEndNode_ReturnsResult() - { - var input = """ - <[|div id="parent"> -
-
-
-

Deeply nested par -

-
-
-
- """; - - var expectedRazorComponent = """ -
-
-
-
-

Deeply nested par

-
-
-
-
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - - [Fact] - public async Task Handle_ExtractComponent_SiblingElement_ReturnsResult() - { - var input = """ - <[|div id="a"> -

Div a title

- -

Div a par

- -
- - - """; - - var expectedRazorComponent = """ -
-

Div a title

- -

Div a par

-
-
- -
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - - [Fact] - public async Task Handle_ExtractComponent_StartNodeContainsEndNode_ReturnsResult() - { - var input = """ - <[|div id="parent"> -
-
-
-

Deeply nested par -

-
-
-
- """; - - var expectedRazorComponent = """ -
-
-
-
-

Deeply nested par

-
-
-
-
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - - [Fact] - public async Task Handle_ExtractComponent_IndentedNode_ReturnsResult() - { - var input = """ -
-
- <[||]div> -
-

Deeply nested par

-
-
-
- - """; - - var expectedRazorComponent = """ -
-
-

Deeply nested par

-
-
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - - [Fact] - public async Task Handle_ExtractComponent_IndentedSiblingNodes_ReturnsResult() - { - var input = """ -
-
-
-
- <[|div> -

Deeply nested par

-
-
-

Deeply nested par

-
-
-

Deeply nested par

-
-
-

Deeply nested par

- -
-
-
-
- """; - - var expectedRazorComponent = """ -
-

Deeply nested par

-
-
-

Deeply nested par

-
-
-

Deeply nested par

-
-
-

Deeply nested par

-
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - - [Fact] - public async Task Handle_ExtractComponent_IndentedStartNodeContainsEndNode_ReturnsResult() - { - var input = """ -
-
- <[|div> -
-

Deeply nested par -

-
-
- - """; - - var expectedRazorComponent = """ -
-
-

Deeply nested par

-
-
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - - [Fact] - public async Task Handle_ExtractComponent_IndentedEndNodeContainsStartNode_ReturnsResult() - { - var input = """ -
-
-
-
- <[|p>Deeply nested par

-
- -
-
- """; - - var expectedRazorComponent = """ -
-
-

Deeply nested par

-
-
- """; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - #endregion private async Task ValidateCodeBehindFileAsync( @@ -1485,61 +1155,6 @@ private async Task ValidateCodeActionAsync( AssertEx.EqualOrDiff(expected, actual); } - private async Task ValidateExtractComponentCodeActionAsync( - string input, - string? expected, - string codeAction, - int childActionIndex = 0, - IEnumerable<(string filePath, string contents)>? additionalRazorDocuments = null, - IRazorCodeActionProvider[]? razorCodeActionProviders = null, - Func? codeActionResolversCreator = null, - RazorLSPOptionsMonitor? optionsMonitor = null, - Diagnostic[]? diagnostics = null) - { - TestFileMarkupParser.GetSpan(input, out input, out var textSpan); - - var razorFilePath = "C:/path/to/test.razor"; - var componentFilePath = "C:/path/to/Component.razor"; - var codeDocument = CreateCodeDocument(input, filePath: razorFilePath); - var sourceText = codeDocument.Source.Text; - var uri = new Uri(razorFilePath); - var languageServer = await CreateLanguageServerAsync(codeDocument, razorFilePath, additionalRazorDocuments); - var documentContext = CreateDocumentContext(uri, codeDocument); - var requestContext = new RazorRequestContext(documentContext, null!, "lsp/method", uri: null); - - var result = await GetCodeActionsAsync( - uri, - textSpan, - sourceText, - requestContext, - languageServer, - razorCodeActionProviders, - diagnostics); - - Assert.NotEmpty(result); - var codeActionToRun = GetCodeActionToRun(codeAction, childActionIndex, result); - - if (expected is null) - { - Assert.Null(codeActionToRun); - return; - } - - Assert.NotNull(codeActionToRun); - - var formattingService = await TestRazorFormattingService.CreateWithFullSupportAsync(LoggerFactory, codeDocument, documentContext.Snapshot, optionsMonitor?.CurrentValue); - var changes = await GetEditsAsync( - codeActionToRun, - requestContext, - languageServer, - codeActionResolversCreator?.Invoke(razorFilePath, codeDocument, formattingService, languageServer, arg5: null) ?? []); - - var edits = changes.Where(change => change.TextDocument.Uri.AbsolutePath == componentFilePath).Single(); - var actual = edits.Edits.Select(edit => edit.NewText).Single(); - - AssertEx.EqualOrDiff(expected, actual); - } - private static VSInternalCodeAction? GetCodeActionToRun(string codeAction, int childActionIndex, SumType[] result) { var codeActionToRun = (VSInternalCodeAction?)result.SingleOrDefault(e => ((RazorVSInternalCodeAction)e.Value!).Name == codeAction || ((RazorVSInternalCodeAction)e.Value!).Title == codeAction).Value; @@ -1772,78 +1387,4 @@ static IEnumerable BuildTagHelpers() } } } - - private class ExtractToComponentResolverDocumentContextFactory : TestDocumentContextFactory - { - private readonly List _tagHelperDescriptors; - - public ExtractToComponentResolverDocumentContextFactory - (string filePath, - RazorCodeDocument codeDocument, - TagHelperDescriptor[]? tagHelpers = null, - int? version = null) - : base(filePath, codeDocument, version) - { - _tagHelperDescriptors = CreateTagHelperDescriptors(); - if (tagHelpers is not null) - { - _tagHelperDescriptors.AddRange(tagHelpers); - } - } - - public override bool TryCreate( - Uri documentUri, - VSProjectContext? projectContext, - bool versioned, - [NotNullWhen(true)] out DocumentContext? context) - { - if (FilePath is null || CodeDocument is null) - { - context = null; - return false; - } - - var projectWorkspaceState = ProjectWorkspaceState.Create(_tagHelperDescriptors.ToImmutableArray()); - var testDocumentSnapshot = TestDocumentSnapshot.Create(FilePath, CodeDocument.GetSourceText().ToString(), CodeAnalysis.VersionStamp.Default, projectWorkspaceState); - testDocumentSnapshot.With(CodeDocument); - - context = CreateDocumentContext(new Uri(FilePath), testDocumentSnapshot); - return true; - } - - private static List CreateTagHelperDescriptors() - { - return BuildTagHelpers().ToList(); - - static IEnumerable BuildTagHelpers() - { - var builder = TagHelperDescriptorBuilder.Create("oncontextmenu", "Microsoft.AspNetCore.Components"); - builder.SetMetadata( - new KeyValuePair(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.MouseEventArgs"), - new KeyValuePair(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind)); - yield return builder.Build(); - - builder = TagHelperDescriptorBuilder.Create("onclick", "Microsoft.AspNetCore.Components"); - builder.SetMetadata( - new KeyValuePair(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.MouseEventArgs"), - new KeyValuePair(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind)); - - yield return builder.Build(); - - builder = TagHelperDescriptorBuilder.Create("oncopy", "Microsoft.AspNetCore.Components"); - builder.SetMetadata( - new KeyValuePair(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.ClipboardEventArgs"), - new KeyValuePair(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind)); - - yield return builder.Build(); - - builder = TagHelperDescriptorBuilder.Create("ref", "Microsoft.AspNetCore.Components"); - builder.SetMetadata( - new KeyValuePair(ComponentMetadata.SpecialKindKey, ComponentMetadata.Ref.TagHelperKind), - new KeyValuePair(ComponentMetadata.Common.DirectiveAttribute, bool.TrueString)); - - yield return builder.Build(); - } - } - } } From b37e6d81b8e339a77714f2e1a3dfcd351d652bd4 Mon Sep 17 00:00:00 2001 From: marcarro Date: Mon, 2 Sep 2024 21:52:19 -0700 Subject: [PATCH 20/29] Updated functionality to match Roslyn endpoint response, finished attribute promotion functionality, Resolver now handles event handlers and data binding --- .../ExtractToComponentCodeActionProvider.cs | 30 +- .../ExtractToComponentCodeActionResolver.cs | 415 +++++++++--------- .../CodeActions/GetSymbolicInfoParams.cs | 13 +- ...azorCustomMessageTarget_GetSymbolicInfo.cs | 6 +- ...onEndToEndTest.ExtractToComponent.NetFx.cs | 69 --- 5 files changed, 221 insertions(+), 312 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index f5639de9571..b787b89a9e6 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -38,7 +38,7 @@ public Task> ProvideAsync(RazorCodeAct } var syntaxTree = context.CodeDocument.GetSyntaxTree(); - if (!IsSelectionValid(context, syntaxTree)) + if (!IsValidSelection(context, syntaxTree)) { return SpecializedTasks.EmptyImmutableArray(); } @@ -80,7 +80,7 @@ private static bool IsValidContext(RazorCodeActionContext context) context.CodeDocument.GetSyntaxTree()?.Root is not null; } - private bool IsSelectionValid(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) + private bool IsValidSelection(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) { var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); if (owner is null) @@ -90,33 +90,33 @@ private bool IsSelectionValid(RazorCodeActionContext context, RazorSyntaxTree sy } var startElementNode = owner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); - return startElementNode is not null && !HasDiagnosticErrors(startElementNode) && !IsInsideProperHtmlContent(context, owner); + return startElementNode is not null && HasNoDiagnosticErrors(startElementNode) && IsInsideMarkupTag(context, owner); } - private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, SyntaxNode owner) + private static bool IsInsideMarkupTag(RazorCodeActionContext context, SyntaxNode owner) { // The selection could start either in a MarkupElement or MarkupTagHelperElement. - // Both of these have the necessary properties to do this check, but not the base MarkupSyntaxNode. - // The workaround for this is to try to cast to the specific types and then do the check. + // Both of these have the necessary properties to do this check, but the base class MarkupSyntaxNode does not. + // The workaround for this is to try to find the specific types as ancestors and then do the check. var tryMakeMarkupElement = owner.FirstAncestorOrSelf(); var tryMakeMarkupTagHelperElement = owner.FirstAncestorOrSelf(); - var isLocationInProperMarkupElement = tryMakeMarkupElement is not null && - context.Location.AbsoluteIndex > tryMakeMarkupElement.StartTag.Span.End && - context.Location.AbsoluteIndex < tryMakeMarkupElement.EndTag.SpanStart; + var isLocationInElementTag = tryMakeMarkupElement is not null && + (tryMakeMarkupElement.StartTag.Span.Contains(context.Location.AbsoluteIndex) || + tryMakeMarkupElement.EndTag.Span.Contains(context.Location.AbsoluteIndex)); - var isLocationInProperMarkupTagHelper = tryMakeMarkupTagHelperElement is not null && - context.Location.AbsoluteIndex > tryMakeMarkupTagHelperElement.StartTag.Span.End && - context.Location.AbsoluteIndex < tryMakeMarkupTagHelperElement.EndTag.SpanStart; + var isLocationInTagHelperTag = tryMakeMarkupTagHelperElement is not null && + (tryMakeMarkupTagHelperElement.StartTag.Span.Contains(context.Location.AbsoluteIndex) || + tryMakeMarkupTagHelperElement.EndTag.Span.Contains(context.Location.AbsoluteIndex)); - return isLocationInProperMarkupElement || isLocationInProperMarkupTagHelper; + return isLocationInElementTag || isLocationInTagHelperTag; } - private static bool HasDiagnosticErrors(MarkupSyntaxNode markupElement) + private static bool HasNoDiagnosticErrors(MarkupSyntaxNode markupElement) { var diagnostics = markupElement.GetDiagnostics(); - return diagnostics.Any(d => d.Severity == RazorDiagnosticSeverity.Error); + return !diagnostics.Any(d => d.Severity == RazorDiagnosticSeverity.Error); } private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen(returnValue: true)] out string? @namespace) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 6843547b9d7..17cb28c32b2 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -41,6 +41,7 @@ using System.Reflection.Metadata.Ecma335; using Microsoft.VisualStudio.Utilities; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; +using Microsoft.AspNetCore.Razor.LanguageServer.Diagnostics; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -124,7 +125,14 @@ internal sealed class ExtractToComponentCodeActionResolver( }.Uri; var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, documentContext, removeRange, whitespace, cancellationToken).ConfigureAwait(false); + var newComponentResult = await GenerateNewComponentAsync( + selectionAnalysis, + codeDocument, + actionParams.Uri, + documentContext, + removeRange, + whitespace, + cancellationToken).ConfigureAwait(false); if (newComponentResult is null) { @@ -132,7 +140,7 @@ internal sealed class ExtractToComponentCodeActionResolver( } var newComponentContent = newComponentResult.NewContents; - var componentNameAndParams = GenerateComponentNameAndParameters(newComponentResult.Methods, componentName); + var componentNameAndParams = GenerateComponentNameAndParameters(newComponentResult.Methods, newComponentResult.Attributes, componentName); var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; @@ -184,8 +192,9 @@ internal sealed record SelectionAnalysisResult public required bool Success; public int ExtractStart; public int ExtractEnd; + public bool HasAtCodeBlock; + public bool HasEventHandlerOrExpression; public HashSet? UsingDirectives; - public HashSet? TentativeVariableDependencies; } private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) @@ -197,13 +206,14 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod } endElementNode ??= startElementNode; - + var success = TryProcessSelection(startElementNode, endElementNode, codeDocument, actionParams, out var extractStart, - out var extractEnd); + out var extractEnd, + out var hasAtCodeBlock); if (!success) { @@ -211,16 +221,17 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod } var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - var componentDependencies = AddUsingDirectivesInRange(dependencyScanRoot, extractStart, extractEnd); - var variableDependencies = AddVariableDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); + var usingDirectives = GetUsingDirectivesInRange(dependencyScanRoot, extractStart, extractEnd); + var hasOtherIdentifiers = CheckHasOtherIdentifiers(dependencyScanRoot, extractStart, extractEnd); return new SelectionAnalysisResult { Success = success, ExtractStart = extractStart, ExtractEnd = extractEnd, - UsingDirectives = componentDependencies, - TentativeVariableDependencies = variableDependencies, + HasAtCodeBlock = hasAtCodeBlock, + HasEventHandlerOrExpression = hasOtherIdentifiers, + UsingDirectives = usingDirectives, }; } @@ -303,11 +314,20 @@ private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndEl /// The parameters for the extraction action /// The start of the extraction range. /// The end of the extraction range + /// Whether the selection has a @code block /// true if the selection was successfully processed; otherwise, false. - private static bool TryProcessSelection(MarkupSyntaxNode startElementNode, MarkupSyntaxNode endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams, out int extractStart, out int extractEnd) + private static bool TryProcessSelection( + MarkupSyntaxNode startElementNode, + MarkupSyntaxNode endElementNode, + RazorCodeDocument codeDocument, + ExtractToComponentCodeActionParams actionParams, + out int extractStart, + out int extractEnd, + out bool hasCodeBlock) { extractStart = startElementNode.Span.Start; extractEnd = endElementNode.Span.End; + hasCodeBlock = false; // Check if it's a multi-point selection if (actionParams.SelectStart == actionParams.SelectEnd) @@ -372,6 +392,7 @@ private static bool TryProcessSelection(MarkupSyntaxNode startElementNode, Marku if (endCodeBlock is not null) { + hasCodeBlock = true; var (withCodeBlockStart, withCodeBlockEnd) = FindContainingSiblingPair(startElementNode, endCodeBlock); extractStart = withCodeBlockStart?.Span.Start ?? extractStart; extractEnd = withCodeBlockEnd?.Span.End ?? extractEnd; @@ -380,6 +401,13 @@ private static bool TryProcessSelection(MarkupSyntaxNode startElementNode, Marku return true; } + /// + /// Finds the smallest set of sibling nodes that contain both the start and end nodes. + /// This is useful for determining the scope of a selection that spans across different levels of the syntax tree. + /// + /// The node where the selection starts. + /// The node where the selection ends. + /// A tuple containing the start and end nodes of the containing sibling pair. private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) { // Find the lowest common ancestor of both nodes @@ -411,11 +439,13 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy startContainingNode = child; } + // Check if this child contains the end node if (childSpan.Contains(endSpan)) { endContainingNode = child; } + // If we've found both containing nodes, we can stop searching if (startContainingNode is not null && endContainingNode is not null) { break; @@ -445,14 +475,19 @@ private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) return node is MarkupElementSyntax or MarkupTagHelperElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); } - private static HashSet AddUsingDirectivesInRange(SyntaxNode root, int extractStart, int extractEnd) + private static HashSet GetUsingDirectivesInRange(SyntaxNode root, int extractStart, int extractEnd) { var usings = new HashSet(); var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); // Only analyze nodes within the extract span - foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) + foreach (var node in root.DescendantNodes()) { + if (!extractSpan.Contains(node.Span)) + { + continue; + } + if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) { AddUsingFromTagHelperInfo(tagHelperInfo, usings); @@ -462,26 +497,57 @@ private static HashSet AddUsingDirectivesInRange(SyntaxNode root, int ex return usings; } - private static HashSet AddVariableDependenciesInRange(SyntaxNode root, int extractStart, int extractEnd) + private static bool CheckHasOtherIdentifiers(SyntaxNode root, int extractStart, int extractEnd) { - var dependencies = new HashSet(); var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); - var candidates = root.DescendantNodes().Where(node => extractSpan.Contains(node.Span)); - - foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) + foreach (var node in root.DescendantNodes()) { - if (node is MarkupTagHelperAttributeValueSyntax tagAttribute) + if (!extractSpan.Contains(node.Span)) { - dependencies.Add(tagAttribute.ToFullString()); + continue; } - else if (node is CSharpImplicitExpressionBodySyntax implicitExpression) + + // An assumption I'm making that might be wrong: + // CSharpImplicitExpressionBodySyntax, CSharpExplicitExpressionBodySyntax, and MarkupTagHelperDirectiveAttributeSyntax + // nodes contain only one child of type CSharpExpressionLiteralSyntax + + // For MarkupTagHelperDirectiveAttributeSyntax, the syntax tree seems to show only one child of the contained CSharpExpressionLiteral as Text, + // so it might not be worth it to check for identifiers, but only if the above is true in all cases. + + if (node is CSharpImplicitExpressionBodySyntax or CSharpExplicitExpressionBodySyntax) { - dependencies.Add(implicitExpression.ToFullString()); + + var expressionLiteral = node.DescendantNodes().OfType().SingleOrDefault(); + if (expressionLiteral is null) + { + continue; + } + + foreach (var token in expressionLiteral.LiteralTokens) + { + if (token.Kind is Language.SyntaxKind.Identifier) + { + return true; + } + } + } + else if (node is MarkupTagHelperDirectiveAttributeSyntax directiveAttribute) + { + var attributeDelegate = directiveAttribute.DescendantNodes().OfType().SingleOrDefault(); + if (attributeDelegate is null) + { + continue; + } + + if (attributeDelegate.LiteralTokens.FirstOrDefault() is Language.Syntax.SyntaxToken { Kind: Language.SyntaxKind.Text }) + { + return true; + } } } - return dependencies; + return false; } private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet dependencies) @@ -498,12 +564,16 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS } } + /// + /// Generates a new Razor component based on the selected content from existing markup. + /// This method handles the extraction of code, processing of C# elements, and creation of necessary parameters. + /// private async Task GenerateNewComponentAsync( SelectionAnalysisResult selectionAnalysis, RazorCodeDocument razorCodeDocument, Uri componentUri, DocumentContext documentContext, - Range relevantRange, + Range selectionRange, string whitespace, CancellationToken cancellationToken) { @@ -513,8 +583,8 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS return null; } - var inst = PooledStringBuilder.GetInstance(); - var newFileContentBuilder = inst.Builder; + var sbInstance = PooledStringBuilder.GetInstance(); + var newFileContentBuilder = sbInstance.Builder; if (selectionAnalysis.UsingDirectives is not null) { foreach (var dependency in selectionAnalysis.UsingDirectives) @@ -533,7 +603,7 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)) .Trim(); - // Go through each line of the extractedContents and remove the whitespace from the beginning of each line. + // Remove leading whitespace from each line to maintain proper indentation in the new component var extractedLines = extractedContents.Split('\n'); for (var i = 1; i < extractedLines.Length; i++) { @@ -547,51 +617,57 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS extractedContents = string.Join("\n", extractedLines); newFileContentBuilder.Append(extractedContents); - // Get CSharpStatements within component - var syntaxTree = razorCodeDocument.GetSyntaxTree(); - var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd); - var result = new NewRazorComponentInfo { - NewContents = newFileContentBuilder.ToString(), - Methods = [] + NewContents = newFileContentBuilder.ToString() }; - // Only make the Roslyn call if there is valid CSharp in the selected code. - if (cSharpCodeBlocks.Count == 0) - { - inst.Free(); + // Get CSharpStatements within component + var syntaxTree = razorCodeDocument.GetSyntaxTree(); + var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd, out var atCodeBlock); + + // Only make the Roslyn call if there is CSharp in the selected code + // (code blocks, expressions, event handlers, binders) in the selected code, + // or if the selection doesn't already include the @code block. + // Assuming that if a user selects a @code along with markup, the @code block contains all necessary information for the component. + if (selectionAnalysis.HasAtCodeBlock || + atCodeBlock is null || + (!selectionAnalysis.HasEventHandlerOrExpression && + cSharpCodeBlocks.Count == 0)) + { + sbInstance.Free(); return result; } if (!_documentVersionCache.TryGetDocumentVersion(documentContext.Snapshot, out var version)) { - inst.Free(); - return result; + sbInstance.Free(); + throw new InvalidOperationException("Failed to retrieve document version."); } - + var sourceMappings = razorCodeDocument.GetCSharpDocument().SourceMappings; + var cSharpDocument = razorCodeDocument.GetCSharpDocument(); + var sourceText = razorCodeDocument.Source.Text; + var generatedSourceText = SourceText.From(cSharpDocument.GeneratedCode); + + // Create mappings between the original Razor source and the generated C# code var sourceMappingRanges = sourceMappings.Select(m => ( - new Range - { - Start = new Position(m.OriginalSpan.LineIndex, m.OriginalSpan.CharacterIndex), - End = new Position(m.OriginalSpan.LineIndex, m.OriginalSpan.EndCharacterIndex) - }, + OriginalRange: RazorDiagnosticConverter.ConvertSpanToRange(m.OriginalSpan, sourceText), m.GeneratedSpan - )).ToList(); + )).ToArray(); - var relevantTextSpan = relevantRange.ToTextSpan(razorCodeDocument.Source.Text); - var intersectingGeneratedSpans = sourceMappingRanges.Where(m => relevantRange.IntersectsOrTouches(m.Item1)).Select(m => m.GeneratedSpan).ToArray(); + // Find the spans in the generated C# code that correspond to the selected Razor code + var intersectingGeneratedSpans = sourceMappingRanges + .Where(m => m.OriginalRange != null && selectionRange.IntersectsOrTouches(m.OriginalRange)) + .Select(m => m.GeneratedSpan) + .ToArray(); - // I'm not sure why, but for some reason the endCharacterIndex is lower than the CharacterIndex so they must be swapped. - var intersectingGeneratedRanges = intersectingGeneratedSpans.Select(m => - new Range - { - Start = new Position(m.LineIndex, m.EndCharacterIndex), - End = new Position(m.LineIndex, m.CharacterIndex) - } - ).ToArray(); + var intersectingGeneratedRanges = intersectingGeneratedSpans + .Select(m =>RazorDiagnosticConverter.ConvertSpanToRange(m, generatedSourceText)) + .Where(range => range != null) + .Select(range => range!) + .ToArray(); var parameters = new GetSymbolicInfoParams() { @@ -604,163 +680,83 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS Uri = componentUri }, HostDocumentVersion = version.Value, - IntersectingRangesInGeneratedMappings = intersectingGeneratedRanges + GeneratedDocumentRanges = intersectingGeneratedRanges }; - SymbolicInfo? componentInfo; + MemberSymbolicInfo? componentInfo; + + // Send a request to the language server to get symbolic information about the extracted code try { - componentInfo = await _clientConnection.SendRequestAsync( + componentInfo = await _clientConnection.SendRequestAsync( CustomMessageNames.RazorGetSymbolicInfoEndpointName, parameters, cancellationToken: default).ConfigureAwait(false); } catch (Exception ex) { - throw new InvalidOperationException("Failed to send request to RazorComponentInfoEndpoint", ex); + throw new InvalidOperationException("Failed to send request to Roslyn endpoint", ex); } if (componentInfo is null) { - inst.Free(); - return result; + sbInstance.Free(); + throw new InvalidOperationException("Roslyn endpoint 'GetSymbolicInfo' returned null"); } - var codeBlockAtEnd = GetCodeBlockAtEnd(syntaxTree); - if (codeBlockAtEnd is null) - { - inst.Free(); - return result; - } + // Generate parameter declarations for methods and attributes used in the extracted component + var promotedMethods = GeneratePromotedMethods(componentInfo.Methods); + var promotedAttributes = GeneratePromotedAttributes(componentInfo.Attributes, Path.GetFileName(componentUri.LocalPath)); + var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, promotedAttributes); - var identifiersInCodeBlock = GetIdentifiersInContext(codeBlockAtEnd, cSharpCodeBlocks); - if (componentInfo.Methods is null) + // Capitalize attribute references in the new component to match C# naming conventions + foreach (var attribute in componentInfo.Attributes) { - inst.Free(); - return result; + var capitalizedAttributeName = CapitalizeString(attribute.Name); + newFileContentBuilder.Replace(attribute.Name, capitalizedAttributeName); } - var methodsInFile = componentInfo.Methods.Select(method => method.Name).ToHashSet(); - var methodStringsInContext = methodsInFile.Intersect(identifiersInCodeBlock); - var methodsInContext = GetMethodsInContext(componentInfo, methodStringsInContext); - var promotedMethods = GeneratePromotedMethods(methodsInContext); - - var fieldsInContext = GetFieldsInContext(componentInfo.Fields, identifiersInCodeBlock); - var forwardedFields = GenerateForwardedConstantFields(fieldsInContext, Path.GetFileName(razorCodeDocument.Source.FilePath)); - - var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, forwardedFields); - - ReplaceMethodInvocations(newFileContentBuilder, methodsInContext); newFileContentBuilder.Append(newFileCodeBlock); result.NewContents = newFileContentBuilder.ToString(); - result.Methods = methodsInContext; + result.Methods = componentInfo.Methods; + result.Attributes = componentInfo.Attributes; - inst.Free(); + sbInstance.Free(); return result; } - private static List GetCSharpCodeBlocks(RazorSyntaxTree syntaxTree, int start, int end) + private static List GetCSharpCodeBlocks( + RazorSyntaxTree syntaxTree, + int start, + int end, + out CSharpStatementLiteralSyntax? atCodeBlock) { var root = syntaxTree.Root; var span = new TextSpan(start, end - start); - // Get only CSharpSyntaxNodes without Razor Directives as children or ancestors. This avoids getting the @code block at the end of a razor file. - var razorDirectives = root.DescendantNodes() - .Where(node => node.SpanStart >= start && node.Span.End <= end) - .OfType(); - + // Get only CSharpSyntaxNodes without Razor Meta Code as ancestors. This avoids getting the @code block at the end of a razor file. var cSharpCodeBlocks = root.DescendantNodes() .Where(node => span.Contains(node.Span)) - .OfType() - .Where(csharpNode => - !csharpNode.Ancestors().OfType().Any() && - !razorDirectives.Any(directive => directive.Span.Contains(csharpNode.Span))) + .OfType() + .Where(cSharpNode => + !cSharpNode.Ancestors().OfType().Any()) .ToList(); - return cSharpCodeBlocks; - } - - // Get identifiers in code block to union with the identifiers in the extracted code - private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd, List previousCodeBlocks) - { - var identifiersInLastCodeBlock = new HashSet(); - var identifiersInPreviousCodeBlocks = new HashSet(); - - if (codeBlockAtEnd == null) - { - return identifiersInLastCodeBlock; - } - - foreach (var node in codeBlockAtEnd.DescendantNodes()) - { - if (node.Kind is Language.SyntaxKind.Identifier) - { - var lit = node.ToFullString(); - identifiersInLastCodeBlock.Add(lit); - } - } - - foreach (var previousCodeBlock in previousCodeBlocks) - { - foreach (var node in previousCodeBlock.DescendantNodes()) - { - if (node.Kind is Language.SyntaxKind.Identifier) - { - var lit = node.ToFullString(); - identifiersInPreviousCodeBlocks.Add(lit); - } - } - } - - // Now union with identifiers in other cSharpCodeBlocks in context - identifiersInLastCodeBlock.IntersectWith(identifiersInPreviousCodeBlocks); + atCodeBlock = root.DescendantNodes().OfType().LastOrDefault(); + atCodeBlock = atCodeBlock is not null && cSharpCodeBlocks.Contains(atCodeBlock) ? null : atCodeBlock; - return identifiersInLastCodeBlock; - } - - private static HashSet GetMethodsInContext(SymbolicInfo componentInfo, IEnumerable methodStringsInContext) - { - var methodsInContext = new HashSet(); - if (componentInfo.Methods is null) - { - return methodsInContext; - } - - foreach (var componentMethod in componentInfo.Methods) - { - if (methodStringsInContext.Contains(componentMethod.Name) && !methodsInContext.Any(method => method.Name == componentMethod.Name)) - { - methodsInContext.Add(componentMethod); - } - } - - return methodsInContext; - } - - private static SyntaxNode? GetCodeBlockAtEnd(RazorSyntaxTree syntaxTree) - { - var root = syntaxTree.Root; - - // Get only the last CSharpCodeBlock (has an explicit "@code" transition) - var razorDirectiveAtEnd = root.DescendantNodes().OfType().LastOrDefault(); - - if (razorDirectiveAtEnd is null) - { - return null; - } - - return razorDirectiveAtEnd.Parent; + return cSharpCodeBlocks; } // Create a series of [Parameter] attributes for extracted methods. // Void return functions are promoted to Action delegates. // All other functions should be Func delegates. - private static string GeneratePromotedMethods(HashSet methods) + private static string GeneratePromotedMethods(MethodSymbolicInfo[] methods) { var builder = new StringBuilder(); var parameterCount = 0; - var totalMethods = methods.Count; + var totalMethods = methods.Length; foreach (var method in methods) { @@ -770,7 +766,7 @@ private static string GeneratePromotedMethods(HashSet method builder.AppendLine("[Parameter]"); // Start building delegate type - builder.Append("public "); + builder.Append("required public "); builder.Append(method.ReturnType == "void" ? "Action" : "Func"); // If delegate type is Action, only add generic parameters if needed. @@ -793,7 +789,7 @@ private static string GeneratePromotedMethods(HashSet method builder.Append('>'); } - builder.Append($"Parameter{(parameterCount > 0 ? parameterCount : "")} {{ get; set; }}"); + builder.Append($" {method.Name} {{ get; set; }}"); if (parameterCount < totalMethods - 1) { // Space between methods except for the last method. @@ -807,44 +803,27 @@ private static string GeneratePromotedMethods(HashSet method return builder.ToString(); } - private static HashSet GetFieldsInContext(FieldSymbolicInfo[] fields, HashSet identifiersInCodeBlock) - { - if (fields is null) - { - return []; - } - - var fieldsInContext = new HashSet(); - - foreach (var fieldInfo in fields) - { - if (identifiersInCodeBlock.Contains(fieldInfo.Name)) - { - fieldsInContext.Add(fieldInfo); - } - } - - return fieldsInContext; - } - - // By forwarded fields, I mean fields that are present in the extraction, but get directly added/copied to the extracted component's code block, instead of being passed as an attribute. - // If you have naming suggestions that make more sense, please let me know. - private static string GenerateForwardedConstantFields(HashSet relevantFields, string? sourceDocumentFileName) + private static string GeneratePromotedAttributes(AttributeSymbolicInfo[] relevantFields, string? sourceDocumentFileName) { var builder = new StringBuilder(); var fieldCount = 0; - var totalFields = relevantFields.Count; + var totalFields = relevantFields.Length; foreach (var field in relevantFields) { - if (field.IsValueType || field.Type == "string") + var capitalizedFieldName = CapitalizeString(field.Name); + + if ((field.IsValueType || field.Type == "string") && field.IsWrittenTo) { - builder.AppendLine($"// Warning: Field '{field.Name}' was passed by value and may not be referenced correctly. Please check its usage in the original document: '{sourceDocumentFileName}'."); + builder.AppendLine($"// Warning: Field '{capitalizedFieldName}' was passed by value and may not be referenced correctly. Please check its usage in the original document: '{sourceDocumentFileName}'."); } - builder.AppendLine($"public {field.Type} {field.Name}"); + builder.AppendLine("[Parameter]"); + + // Members cannot be less visible than their enclosing type, so we don't need to check for private fields. + builder.AppendLine($"required public {field.Type} {capitalizedFieldName} {{ get; set; }}"); - if (fieldCount < totalFields - 1) + if (fieldCount++ < totalFields - 1) { builder.AppendLine(); } @@ -853,46 +832,45 @@ private static string GenerateForwardedConstantFields(HashSet return builder.ToString(); } - private static string GenerateNewFileCodeBlock(string promotedMethods, string carryoverFields) + // Most likely out of scope for the class, could be moved elsewhere + private static string CapitalizeString(string str) + { + return str.Length > 0 + ? char.ToUpper(str[0]) + str[1..] + : str; + } + + private static string GenerateNewFileCodeBlock(string promotedMethods, string promotedProperties) { var builder = new StringBuilder(); builder.AppendLine(); builder.AppendLine(); builder.AppendLine("@code {"); - builder.AppendLine(carryoverFields); + builder.AppendLine(promotedProperties); builder.AppendLine(promotedMethods); builder.AppendLine("}"); return builder.ToString(); } - // Method invocations in the new file must be replaced with their respective parameter name. - private static void ReplaceMethodInvocations(StringBuilder newFileContentBuilder, HashSet methods) + private static string GenerateComponentNameAndParameters(MethodSymbolicInfo[]? methods, AttributeSymbolicInfo[]? attributes, string componentName) { - var parameterCount = 0; - foreach (var method in methods) + if (methods is null || attributes is null) { - newFileContentBuilder.Replace(method.Name, $"Parameter{(parameterCount > 0 ? parameterCount : "")}"); - parameterCount++; + return componentName; } - } - private static string GenerateComponentNameAndParameters(HashSet? methods, string componentName) - { var builder = new StringBuilder(); builder.Append(componentName + " "); - var parameterCount = 0; - if (methods is null) + foreach (var method in methods) { - return builder.ToString(); + builder.Append($"{method.Name}={method.Name} "); } - foreach (var method in methods) + foreach (var attribute in attributes) { - builder.Append($"Parameter{(parameterCount > 0 ? parameterCount : "")}"); - builder.Append($"={method.Name}"); - builder.Append(' '); - parameterCount++; + var capitalizedAttributeName = CapitalizeString(attribute.Name); + builder.Append($"{capitalizedAttributeName}={attribute.Name} "); } return builder.ToString(); @@ -901,6 +879,7 @@ private static string GenerateComponentNameAndParameters(HashSet? Methods { get; set; } + public MethodSymbolicInfo[]? Methods { get; set; } + public AttributeSymbolicInfo[]? Attributes { get; set; } } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs index 30c36b52cc9..6a45afb5ca8 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs @@ -23,16 +23,15 @@ internal record GetSymbolicInfoParams [JsonPropertyName("hostDocumentVersion")] public required int HostDocumentVersion { get; set; } - [DataMember(Name = "intersectingRangesInGeneratedMappings")] - [JsonPropertyName("intersectingRangesInGeneratedMappings")] - - public required Range[] IntersectingRangesInGeneratedMappings { get; set; } + [DataMember(Name = "generatedDocumentRanges")] + [JsonPropertyName("generatedDocumentRanges")] + public required Range[] GeneratedDocumentRanges { get; set; } } -internal sealed record SymbolicInfo +internal sealed record MemberSymbolicInfo { public required MethodSymbolicInfo[] Methods { get; set; } - public required FieldSymbolicInfo[] Fields { get; set; } + public required AttributeSymbolicInfo[] Attributes { get; set; } } internal sealed record MethodSymbolicInfo @@ -44,7 +43,7 @@ internal sealed record MethodSymbolicInfo public required string[] ParameterTypes { get; set; } } -internal sealed record FieldSymbolicInfo +internal sealed record AttributeSymbolicInfo { public required string Name { get; set; } public required string Type { get; set; } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs index f813ef24a2d..b1db913a84b 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs @@ -14,7 +14,7 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient.Endpoints; internal partial class RazorCustomMessageTarget { [JsonRpcMethod(CustomMessageNames.RazorGetSymbolicInfoEndpointName, UseSingleObjectParameterDeserialization = true)] - public async Task RazorGetSymbolicInfoAsync(GetSymbolicInfoParams request, CancellationToken cancellationToken) + public async Task RazorGetSymbolicInfoAsync(GetSymbolicInfoParams request, CancellationToken cancellationToken) { var (synchronized, virtualDocumentSnapshot) = await TrySynchronizeVirtualDocumentAsync(request.HostDocumentVersion, request.Document, cancellationToken); if (!synchronized || virtualDocumentSnapshot is null) @@ -23,11 +23,11 @@ internal partial class RazorCustomMessageTarget } request.Document.Uri = virtualDocumentSnapshot.Uri; - ReinvokeResponse response; + ReinvokeResponse response; try { - response = await _requestInvoker.ReinvokeRequestOnServerAsync( + response = await _requestInvoker.ReinvokeRequestOnServerAsync( RazorLSPConstants.RoslynGetSymbolicInfoEndpointName, RazorLSPConstants.RazorCSharpLanguageServerName, request, diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs index 938349e20b7..9d1541339c6 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs @@ -3,41 +3,21 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Components; -using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; -using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; -using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.ExternalAccess.Razor; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; -using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Testing; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.Copilot.Internal; using Microsoft.VisualStudio.LanguageServer.Protocol; using Moq; using Roslyn.Test.Utilities; using Xunit; -using Xunit.Abstractions; -using Xunit.Sdk; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -336,55 +316,6 @@ await ValidateExtractComponentCodeActionAsync( codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } - - [Fact (Skip = "Need to figure out how to use additionalRazorFiles")] - public async Task Handle_ExtractComponent_MarkupWithUsings_ReturnsResult() - { - var input = """ -
- -
- """; - - var book = """ - @namespace BlazorApp1.Shared - -

Title: @Title

-

Written by @Author in @Year

- - - @code { - [Parameter] - public string Title { get; set; } - - [Parameter] - public string Author { get; set; } - - [Parameter] - public string Year { get; set; } - } - """; - - var expectedRazorComponent = """ -
- -
- """; - - var additionalRazorDocuments = new[] - { - ("Book.razor", book) - }; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - additionalRazorDocuments: additionalRazorDocuments, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - private async Task ValidateExtractComponentCodeActionAsync( string input, string? expected, From 5b76f233ff2f70d438af08917ce4ad61652daa7d Mon Sep 17 00:00:00 2001 From: marcarro Date: Tue, 3 Sep 2024 03:28:41 -0700 Subject: [PATCH 21/29] Rebase. Made some mistakes while rebasing, I'm sorry. Integrated previous PR feedback into this branch. --- .../ExtractToCodeBehindCodeActionParams.cs | 6 + .../ExtractToComponentCodeActionParams.cs | 15 ++- .../ExtractToComponentCodeActionProvider.cs | 24 ++-- .../ExtractToComponentCodeActionResolver.cs | 126 ++++++++++++------ ...onEndToEndTest.ExtractToComponent.NetFx.cs | 16 +-- 5 files changed, 109 insertions(+), 78 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToCodeBehindCodeActionParams.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToCodeBehindCodeActionParams.cs index 01704fbc667..08f6a383327 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToCodeBehindCodeActionParams.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToCodeBehindCodeActionParams.cs @@ -18,6 +18,12 @@ internal sealed class ExtractToCodeBehindCodeActionParams [JsonPropertyName("extractEnd")] public int ExtractEnd { get; set; } + [JsonPropertyName("removeStart")] + public int RemoveStart { get; set; } + + [JsonPropertyName("removeEnd")] + public int RemoveEnd { get; set; } + [JsonPropertyName("namespace")] public required string Namespace { get; set; } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs index 3834355e6c5..089d08e3b3d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; @@ -14,15 +15,15 @@ internal sealed class ExtractToComponentCodeActionParams [JsonPropertyName("uri")] public required Uri Uri { get; set; } - [JsonPropertyName("extractStart")] - public int ExtractStart { get; set; } + [JsonPropertyName("selectStart")] + public required Position SelectStart { get; set; } - [JsonPropertyName("extractEnd")] - public int ExtractEnd { get; set; } + [JsonPropertyName("selectEnd")] + public required Position SelectEnd { get; set; } + + [JsonPropertyName("absoluteIndex")] + public required int AbsoluteIndex { get; set; } [JsonPropertyName("namespace")] public required string Namespace { get; set; } - - [JsonPropertyName("usingDirectives")] - public required List usingDirectives { get; set; } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index b787b89a9e6..8046c9f6631 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -43,23 +43,15 @@ public Task> ProvideAsync(RazorCodeAct return SpecializedTasks.EmptyImmutableArray(); } - var actionParams = CreateInitialActionParams(context, startElementNode, @namespace); - - ProcessSelection(startElementNode, endElementNode, actionParams); - - var utilityScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - - // The new component usings are going to be a subset of the usings in the source razor file. - var usingStrings = syntaxTree.Root.DescendantNodes().Where(node => node.IsUsingDirective(out var _)).Select(node => node.ToFullString().TrimEnd()); - - // Get only the namespace after the "using" keyword. - var usingNamespaceStrings = usingStrings.Select(usingString => usingString.Substring("using ".Length)); + var actionParams = new ExtractToComponentCodeActionParams + { + Uri = context.Request.TextDocument.Uri, + SelectStart = context.Request.Range.Start, + SelectEnd = context.Request.Range.End, + AbsoluteIndex = context.Location.AbsoluteIndex, + Namespace = @namespace, + }; - AddUsingDirectivesInRange(utilityScanRoot, - usingNamespaceStrings, - actionParams.ExtractStart, - actionParams.ExtractEnd, - actionParams); var resolutionParams = new RazorCodeActionResolutionParams() { diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 17cb28c32b2..2f2e03b8ca1 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -42,6 +42,7 @@ using Microsoft.VisualStudio.Utilities; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; using Microsoft.AspNetCore.Razor.LanguageServer.Diagnostics; +using Microsoft.AspNetCore.Razor.PooledObjects; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -72,6 +73,8 @@ internal sealed class ExtractToComponentCodeActionResolver( } var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + var syntaxTree = codeDocument.GetSyntaxTree(); + if (codeDocument.IsUnsupported()) { return null; @@ -199,7 +202,10 @@ internal sealed record SelectionAnalysisResult private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) { - var (startElementNode, endElementNode) = GetStartAndEndElements(codeDocument, actionParams); + var syntaxTree = codeDocument.GetSyntaxTree(); + var sourceText = codeDocument.Source.Text; + + var (startElementNode, endElementNode) = GetStartAndEndElements(sourceText, syntaxTree, actionParams); if (startElementNode is null) { return new SelectionAnalysisResult { Success = false }; @@ -221,7 +227,7 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod } var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - var usingDirectives = GetUsingDirectivesInRange(dependencyScanRoot, extractStart, extractEnd); + var usingDirectives = GetUsingDirectivesInRange(syntaxTree, dependencyScanRoot, extractStart, extractEnd); var hasOtherIdentifiers = CheckHasOtherIdentifiers(dependencyScanRoot, extractStart, extractEnd); return new SelectionAnalysisResult @@ -235,9 +241,8 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod }; } - private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndElements(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) + private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndElements(SourceText sourceText, RazorSyntaxTree syntaxTree, ExtractToComponentCodeActionParams actionParams) { - var syntaxTree = codeDocument.GetSyntaxTree(); if (syntaxTree is null) { return (null, null); @@ -255,32 +260,23 @@ private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndEl return (null, null); } - var sourceText = codeDocument.GetSourceText(); - if (sourceText is null) - { - return (null, null); - } - - var endElementNode = TryGetEndElementNode(actionParams.SelectStart, actionParams.SelectEnd, syntaxTree, sourceText); + var endElementNode = GetEndElementNode(sourceText, syntaxTree, actionParams); return (startElementNode, endElementNode); } - private static MarkupSyntaxNode? TryGetEndElementNode(Position selectionStart, Position selectionEnd, RazorSyntaxTree syntaxTree, SourceText sourceText) + private static MarkupSyntaxNode? GetEndElementNode(SourceText sourceText, RazorSyntaxTree syntaxTree, ExtractToComponentCodeActionParams actionParams) { - if (selectionStart == selectionEnd) - { - return null; - } + var selectionStart = actionParams.SelectStart; + var selectionEnd = actionParams.SelectEnd; - var endLocation = GetEndLocation(selectionEnd, sourceText); - if (!endLocation.HasValue) + if (selectionStart == selectionEnd) { return null; } - var endOwner = syntaxTree.Root.FindInnermostNode(endLocation.Value.AbsoluteIndex, true); - + var endAbsoluteIndex = sourceText.GetRequiredAbsoluteIndex(selectionEnd); + var endOwner = syntaxTree.Root.FindInnermostNode(endAbsoluteIndex, true); if (endOwner is null) { return null; @@ -295,16 +291,6 @@ private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndEl return endOwner.FirstAncestorOrSelf(node => node is MarkupTagHelperElementSyntax or MarkupElementSyntax); } - private static SourceLocation? GetEndLocation(Position selectionEnd, SourceText sourceText) - { - if (!selectionEnd.TryGetSourceLocation(sourceText, logger: default, out var location)) - { - return null; - } - - return location; - } - /// /// Processes a selection, providing the start and end of the extraction range if successful. /// @@ -377,13 +363,9 @@ private static bool TryProcessSelection( return true; } - var endLocation = GetEndLocation(actionParams.SelectEnd, codeDocument.GetSourceText()); - if (!endLocation.HasValue) - { - return false; - } + var endLocation = codeDocument.Source.Text.GetRequiredAbsoluteIndex(actionParams.SelectEnd); - var endOwner = codeDocument.GetSyntaxTree().Root.FindInnermostNode(endLocation.Value.AbsoluteIndex, true); + var endOwner = codeDocument.GetSyntaxTree().Root.FindInnermostNode(endLocation, true); var endCodeBlock = endOwner?.FirstAncestorOrSelf(); if (endOwner is not null && endOwner.TryGetPreviousSibling(out var previousSibling)) { @@ -475,8 +457,48 @@ private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) return node is MarkupElementSyntax or MarkupTagHelperElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); } - private static HashSet GetUsingDirectivesInRange(SyntaxNode root, int extractStart, int extractEnd) + private static HashSet GetUsingDirectivesInRange(RazorSyntaxTree syntaxTree, SyntaxNode root, int extractStart, int extractEnd) { + // The new component usings are going to be a subset of the usings in the source razor file. + using var pooledStringArray = new PooledArrayBuilder(); + foreach (var node in syntaxTree.Root.DescendantNodes()) + { + if (node.IsUsingDirective(out var children)) + { + var sb = new StringBuilder(); + var identifierFound = false; + var lastIdentifierIndex = -1; + + // First pass: find the last identifier + for (var i = 0; i < children.Count; i++) + { + if (children[i] is Language.Syntax.SyntaxToken token && token.Kind == Language.SyntaxKind.Identifier) + { + lastIdentifierIndex = i; + } + } + + // Second pass: build the string + for (var i = 0; i <= lastIdentifierIndex; i++) + { + var child = children[i]; + if (child is Language.Syntax.SyntaxToken tkn && tkn.Kind == Language.SyntaxKind.Identifier) + { + identifierFound = true; + } + if (identifierFound) + { + var token = child as Language.Syntax.SyntaxToken; + sb.Append(token?.Content); + } + } + + pooledStringArray.Add(sb.ToString()); + } + } + + var usingsInSourceRazor = pooledStringArray.ToArray(); + var usings = new HashSet(); var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); @@ -490,7 +512,7 @@ private static HashSet GetUsingDirectivesInRange(SyntaxNode root, int ex if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) { - AddUsingFromTagHelperInfo(tagHelperInfo, usings); + AddUsingFromTagHelperInfo(tagHelperInfo, usings, usingsInSourceRazor); } } @@ -550,7 +572,7 @@ private static bool CheckHasOtherIdentifiers(SyntaxNode root, int extractStart, return false; } - private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet dependencies) + private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet usings, string[] usingsInSourceRazor) { foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) { @@ -560,7 +582,31 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS } var typeNamespace = descriptor.GetTypeNamespace(); - dependencies.Add(typeNamespace); + + // Since the using directive at the top of the file may be relative and not absolute, + // we need to generate all possible partial namespaces from `typeNamespace`. + + // Potentially, the alternative could be to ask if the using namespace at the top is a substring of `typeNamespace`. + // The only potential edge case is if there are very similar namespaces where one + // is a substring of the other, but they're actually different (e.g., "My.App" and "My.Apple"). + + // Generate all possible partial namespaces from `typeNamespace`, from least to most specific + // (assuming that the user writes absolute `using` namespaces most of the time) + + // This is a bit inefficient because at most 'k' string operations are performed (k = parts in the namespace), + // for each potential using directive. + + var parts = typeNamespace.Split('.'); + for (var i = 0; i < parts.Length; i++) + { + var partialNamespace = string.Join(".", parts.Skip(i)); + + if (usingsInSourceRazor.Contains(partialNamespace)) + { + usings.Add(partialNamespace); + break; + } + } } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs index 9d1541339c6..4ee660fc1d8 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs @@ -331,24 +331,10 @@ private async Task ValidateExtractComponentCodeActionAsync( var razorFilePath = "C:/path/Test.razor"; var componentFilePath = "C:/path/Component.razor"; var codeDocument = CreateCodeDocument(input, filePath: razorFilePath); - var sourceText = codeDocument.GetSourceText(); + var sourceText = codeDocument.Source.Text; var uri = new Uri(razorFilePath); var languageServer = await CreateLanguageServerAsync(codeDocument, razorFilePath, additionalRazorDocuments); - //var projectManager = CreateProjectSnapshotManager(); - - //await projectManager.UpdateAsync(updater => - //{ - // updater.ProjectAdded(new( - // projectFilePath: "C:/path/to/project.csproj", - // intermediateOutputPath: "C:/path/to/obj", - // razorConfiguration: RazorConfiguration.Default, - // rootNamespace: "project")); - //}); - - //var componentSearchEngine = new DefaultRazorComponentSearchEngine(projectManager, LoggerFactory); - //var componentDefinitionService = new RazorComponentDe - var documentContext = CreateDocumentContext(uri, codeDocument); var requestContext = new RazorRequestContext(documentContext, null!, "lsp/method", uri: null); From cec455e7a611346112ebfb3b94991b10f11c5245 Mon Sep 17 00:00:00 2001 From: marcarro Date: Tue, 3 Sep 2024 12:43:36 -0700 Subject: [PATCH 22/29] Updated to reflect changes in Roslyn API --- .../ExtractToComponentCodeActionResolver.cs | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 2f2e03b8ca1..88805b2f120 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -806,22 +806,20 @@ private static string GeneratePromotedMethods(MethodSymbolicInfo[] methods) foreach (var method in methods) { - builder.AppendLine("/// "); - builder.AppendLine($"/// Delegate for the '{method.Name}' method."); - builder.AppendLine("/// "); - builder.AppendLine("[Parameter]"); + builder.AppendLine($"\t/// Delegate for the '{method.Name}' method."); + builder.AppendLine("\t[Parameter]"); // Start building delegate type - builder.Append("required public "); - builder.Append(method.ReturnType == "void" ? "Action" : "Func"); + builder.Append("\trequired public "); + builder.Append(method.ReturnType == "Void" ? "Action" : "Func"); // If delegate type is Action, only add generic parameters if needed. - if (method.ParameterTypes.Length > 0 || method.ReturnType != "void") + if (method.ParameterTypes.Length > 0 || method.ReturnType != "Void") { builder.Append('<'); builder.Append(string.Join(", ", method.ParameterTypes)); - if (method.ReturnType != "void") + if (method.ReturnType != "Void") { if (method.ParameterTypes.Length > 0) { @@ -836,42 +834,41 @@ private static string GeneratePromotedMethods(MethodSymbolicInfo[] methods) } builder.Append($" {method.Name} {{ get; set; }}"); - if (parameterCount < totalMethods - 1) + if (parameterCount++ < totalMethods - 1) { // Space between methods except for the last method. builder.AppendLine(); builder.AppendLine(); } - - parameterCount++; } return builder.ToString(); } - private static string GeneratePromotedAttributes(AttributeSymbolicInfo[] relevantFields, string? sourceDocumentFileName) + private static string GeneratePromotedAttributes(AttributeSymbolicInfo[] attributes, string? sourceDocumentFileName) { var builder = new StringBuilder(); - var fieldCount = 0; - var totalFields = relevantFields.Length; + var attributeCount = 0; + var totalAttributes = attributes.Length; - foreach (var field in relevantFields) + foreach (var field in attributes) { var capitalizedFieldName = CapitalizeString(field.Name); - if ((field.IsValueType || field.Type == "string") && field.IsWrittenTo) + if ((field.IsValueType || field.Type == "String") && field.IsWrittenTo) { - builder.AppendLine($"// Warning: Field '{capitalizedFieldName}' was passed by value and may not be referenced correctly. Please check its usage in the original document: '{sourceDocumentFileName}'."); + builder.AppendLine($"\t// Warning: Field '{capitalizedFieldName}' was passed by value and may not be referenced correctly. Please check its usage in the original document: '{sourceDocumentFileName}'."); } - builder.AppendLine("[Parameter]"); + builder.AppendLine("\t[Parameter]"); // Members cannot be less visible than their enclosing type, so we don't need to check for private fields. - builder.AppendLine($"required public {field.Type} {capitalizedFieldName} {{ get; set; }}"); + builder.Append($"\trequired public {field.Type} {capitalizedFieldName} {{ get; set; }}"); - if (fieldCount++ < totalFields - 1) + if (attributeCount++ < totalAttributes - 1) { builder.AppendLine(); + builder.AppendLine(); } } @@ -892,9 +889,21 @@ private static string GenerateNewFileCodeBlock(string promotedMethods, string pr builder.AppendLine(); builder.AppendLine(); builder.AppendLine("@code {"); - builder.AppendLine(promotedProperties); - builder.AppendLine(promotedMethods); - builder.AppendLine("}"); + if (promotedProperties.Length > 0) + { + builder.AppendLine(promotedProperties); + } + + if (promotedProperties.Length > 0 && promotedMethods.Length > 0) + { + builder.AppendLine(); + } + + if (promotedMethods.Length > 0) + { + builder.AppendLine(promotedMethods); + } + builder.Append("}"); return builder.ToString(); } From d76d535de1d7f1d0eb044eba5710464bab79ed68 Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 4 Sep 2024 07:39:04 -0700 Subject: [PATCH 23/29] PR Feedback --- .../ExtractToComponentCodeActionProvider.cs | 36 +++++++---- .../ExtractToComponentCodeActionResolver.cs | 62 ++++++------------- 2 files changed, 45 insertions(+), 53 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index 8046c9f6631..64dd07f208a 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -52,7 +52,6 @@ public Task> ProvideAsync(RazorCodeAct Namespace = @namespace, }; - var resolutionParams = new RazorCodeActionResolutionParams() { Action = LanguageServerConstants.CodeActions.ExtractToComponentAction, @@ -69,7 +68,8 @@ private static bool IsValidContext(RazorCodeActionContext context) return context is not null && context.SupportsFileCreation && FileKinds.IsComponent(context.CodeDocument.GetFileKind()) && - context.CodeDocument.GetSyntaxTree()?.Root is not null; + !context.CodeDocument.IsUnsupported() && + context.CodeDocument.GetSyntaxTree() is not null; } private bool IsValidSelection(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) @@ -82,7 +82,7 @@ private bool IsValidSelection(RazorCodeActionContext context, RazorSyntaxTree sy } var startElementNode = owner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); - return startElementNode is not null && HasNoDiagnosticErrors(startElementNode) && IsInsideMarkupTag(context, owner); + return startElementNode is not null && !startElementNode.GetDiagnostics().Any(d => d.Severity == RazorDiagnosticSeverity.Error) && IsInsideMarkupTag(context, owner); } private static bool IsInsideMarkupTag(RazorCodeActionContext context, SyntaxNode owner) @@ -91,8 +91,26 @@ private static bool IsInsideMarkupTag(RazorCodeActionContext context, SyntaxNode // Both of these have the necessary properties to do this check, but the base class MarkupSyntaxNode does not. // The workaround for this is to try to find the specific types as ancestors and then do the check. - var tryMakeMarkupElement = owner.FirstAncestorOrSelf(); - var tryMakeMarkupTagHelperElement = owner.FirstAncestorOrSelf(); + MarkupElementSyntax? tryMakeMarkupElement = null; + MarkupTagHelperElementSyntax? tryMakeMarkupTagHelperElement = null; + + // Basically a custom form of FirstAncestorOrSelf for this specific case + for (var node = owner; node is not null; node = node.Parent) + { + if (node is MarkupElementSyntax markupElement) + { + tryMakeMarkupElement = markupElement; + } + else if (node is MarkupTagHelperElementSyntax tagHelper) + { + tryMakeMarkupTagHelperElement = tagHelper; + } + + if (tryMakeMarkupElement is not null && tryMakeMarkupTagHelperElement is not null) + { + break; + } + } var isLocationInElementTag = tryMakeMarkupElement is not null && (tryMakeMarkupElement.StartTag.Span.Contains(context.Location.AbsoluteIndex) || @@ -105,16 +123,12 @@ private static bool IsInsideMarkupTag(RazorCodeActionContext context, SyntaxNode return isLocationInElementTag || isLocationInTagHelperTag; } - private static bool HasNoDiagnosticErrors(MarkupSyntaxNode markupElement) - { - var diagnostics = markupElement.GetDiagnostics(); - return !diagnostics.Any(d => d.Severity == RazorDiagnosticSeverity.Error); - } - private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen(returnValue: true)] out string? @namespace) // If the compiler can't provide a computed namespace it will fallback to "__GeneratedComponent" or // similar for the NamespaceNode. This would end up with extracting to a wrong namespace // and causing compiler errors. Avoid offering this refactoring if we can't accurately get a // good namespace to extract to => codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out @namespace); + + } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 88805b2f120..c48da4444fa 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -6,43 +6,43 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Reflection.Metadata.Ecma335; +using System.Security.AccessControl; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using ICSharpCode.Decompiler.CSharp.Syntax; +using ICSharpCode.Decompiler.Semantics; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; +using Microsoft.AspNetCore.Razor.LanguageServer.Diagnostics; +using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; +using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Differencing; +using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.CodeAnalysis.Razor.Workspaces; -using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Utilities; using Newtonsoft.Json.Linq; using static System.Net.Mime.MediaTypeNames; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Formatting; -using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; -using Microsoft.CodeAnalysis.Differencing; -using Microsoft.AspNetCore.Razor.Language.Syntax; -using ICSharpCode.Decompiler.Semantics; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; -using System.Security.AccessControl; -using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; using static Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor.ExtractToComponentCodeActionProvider; -using Microsoft.VisualStudio.Text; -using ICSharpCode.Decompiler.CSharp.Syntax; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; -using System.Reflection.Metadata.Ecma335; -using Microsoft.VisualStudio.Utilities; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; -using Microsoft.AspNetCore.Razor.LanguageServer.Diagnostics; -using Microsoft.AspNetCore.Razor.PooledObjects; +using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -61,7 +61,7 @@ internal sealed class ExtractToComponentCodeActionResolver( public async Task ResolveAsync(JsonElement data, CancellationToken cancellationToken) { - var actionParams = DeserializeActionParams(data); + var actionParams = data.Deserialize(); if (actionParams is null) { return null; @@ -74,16 +74,6 @@ internal sealed class ExtractToComponentCodeActionResolver( var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); var syntaxTree = codeDocument.GetSyntaxTree(); - - if (codeDocument.IsUnsupported()) - { - return null; - } - - if (!FileKinds.IsComponent(codeDocument.GetFileKind())) - { - return null; - } var selectionAnalysis = TryAnalyzeSelection(codeDocument, actionParams); if (!selectionAnalysis.Success) @@ -183,13 +173,6 @@ internal sealed class ExtractToComponentCodeActionResolver( }; } - private static ExtractToComponentCodeActionParams? DeserializeActionParams(JsonElement data) - { - return data.ValueKind == JsonValueKind.Undefined - ? null - : JsonSerializer.Deserialize(data.GetRawText()); - } - internal sealed record SelectionAnalysisResult { public required bool Success; @@ -243,11 +226,6 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndElements(SourceText sourceText, RazorSyntaxTree syntaxTree, ExtractToComponentCodeActionParams actionParams) { - if (syntaxTree is null) - { - return (null, null); - } - var owner = syntaxTree.Root.FindInnermostNode(actionParams.AbsoluteIndex, includeWhitespace: true); if (owner is null) { From e08e1d352cbbb3bbb7d6e3fec997c63b8d7d3064 Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 4 Sep 2024 13:45:35 -0700 Subject: [PATCH 24/29] Corrected logic for searching through ancestors in IsInsideMarkupTag --- .../Razor/ExtractToComponentCodeActionProvider.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index 64dd07f208a..c9086c617b3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; +using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Threading; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Razor.Logging; @@ -21,12 +22,17 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; -internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory loggerFactory) : IRazorCodeActionProvider +internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory loggerFactory, ITelemetryReporter telemetryReporter) : IRazorCodeActionProvider { private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + private readonly ITelemetryReporter _telemetryReporter = telemetryReporter; public Task> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) { + var correlationId = Guid.NewGuid(); + using var _ = _telemetryReporter.TrackLspRequest(LanguageServerConstants.CodeActions.ExtractToComponentAction, LanguageServerConstants.RazorLanguageServerName, correlationId); + var telemetryBlock = _telemetryReporter.BeginBlock("ETCProvider", Severity.Normal); + if (!IsValidContext(context)) { return SpecializedTasks.EmptyImmutableArray(); @@ -60,6 +66,8 @@ public Task> ProvideAsync(RazorCodeAct }; var codeAction = RazorCodeActionFactory.CreateExtractToComponent(resolutionParams); + + _telemetryReporter.ReportEvent("extractToComponentProvider/actionProvided", Severity.Normal); return Task.FromResult>([codeAction]); } @@ -97,11 +105,11 @@ private static bool IsInsideMarkupTag(RazorCodeActionContext context, SyntaxNode // Basically a custom form of FirstAncestorOrSelf for this specific case for (var node = owner; node is not null; node = node.Parent) { - if (node is MarkupElementSyntax markupElement) + if (tryMakeMarkupElement is null && node is MarkupElementSyntax markupElement) { tryMakeMarkupElement = markupElement; } - else if (node is MarkupTagHelperElementSyntax tagHelper) + else if (tryMakeMarkupTagHelperElement is null && node is MarkupTagHelperElementSyntax tagHelper) { tryMakeMarkupTagHelperElement = tagHelper; } From 1dfb8fd2a5dc2ea51f2d300ad186eaf7a8b83bdb Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 4 Sep 2024 21:27:55 -0700 Subject: [PATCH 25/29] Cleaned up usings and integrated PR Feedback for IsInsideMarkupTag --- .../ExtractToComponentCodeActionProvider.cs | 11 +++-------- .../ExtractToComponentCodeActionResolver.cs | 16 ---------------- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index c9086c617b3..46bfacf313d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -1,8 +1,6 @@ // 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.CodeDom; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; @@ -14,9 +12,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Threading; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; @@ -29,9 +25,8 @@ internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory logger public Task> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) { - var correlationId = Guid.NewGuid(); - using var _ = _telemetryReporter.TrackLspRequest(LanguageServerConstants.CodeActions.ExtractToComponentAction, LanguageServerConstants.RazorLanguageServerName, correlationId); - var telemetryBlock = _telemetryReporter.BeginBlock("ETCProvider", Severity.Normal); + var telemetryDidSucceed = false; + using var _ = _telemetryReporter.BeginBlock("extractToComponentProvider", Severity.Normal, new Property("didSucceed", telemetryDidSucceed)); if (!IsValidContext(context)) { @@ -67,7 +62,7 @@ public Task> ProvideAsync(RazorCodeAct var codeAction = RazorCodeActionFactory.CreateExtractToComponent(resolutionParams); - _telemetryReporter.ReportEvent("extractToComponentProvider/actionProvided", Severity.Normal); + telemetryDidSucceed = true; return Task.FromResult>([codeAction]); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index c48da4444fa..0f279b95bab 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -3,44 +3,28 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; -using System.Reflection.Metadata.Ecma335; -using System.Security.AccessControl; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using ICSharpCode.Decompiler.CSharp.Syntax; -using ICSharpCode.Decompiler.Semantics; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.LanguageServer.Diagnostics; -using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; -using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Differencing; -using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; -using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Utilities; -using Newtonsoft.Json.Linq; -using static System.Net.Mime.MediaTypeNames; -using static Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor.ExtractToComponentCodeActionProvider; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; From 268e3034d290f02ef82a9b5471e2a4fb1b610c2a Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 4 Sep 2024 21:30:20 -0700 Subject: [PATCH 26/29] Ignore previous commit message! Intended previous commit message: Telemetry was added to provider in previous commit, and cleaned up usings. This commit: Integrated PR Feedback for IsInsideMarkupTag --- .../ExtractToComponentCodeActionProvider.cs | 42 +++++++------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index 46bfacf313d..905ea167653 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -90,40 +90,26 @@ private bool IsValidSelection(RazorCodeActionContext context, RazorSyntaxTree sy private static bool IsInsideMarkupTag(RazorCodeActionContext context, SyntaxNode owner) { - // The selection could start either in a MarkupElement or MarkupTagHelperElement. - // Both of these have the necessary properties to do this check, but the base class MarkupSyntaxNode does not. - // The workaround for this is to try to find the specific types as ancestors and then do the check. + var (startTag, endTag) = GetStartAndEndTag(owner); - MarkupElementSyntax? tryMakeMarkupElement = null; - MarkupTagHelperElementSyntax? tryMakeMarkupTagHelperElement = null; - - // Basically a custom form of FirstAncestorOrSelf for this specific case - for (var node = owner; node is not null; node = node.Parent) + if (startTag is null || endTag is null) { - if (tryMakeMarkupElement is null && node is MarkupElementSyntax markupElement) - { - tryMakeMarkupElement = markupElement; - } - else if (tryMakeMarkupTagHelperElement is null && node is MarkupTagHelperElementSyntax tagHelper) - { - tryMakeMarkupTagHelperElement = tagHelper; - } - - if (tryMakeMarkupElement is not null && tryMakeMarkupTagHelperElement is not null) - { - break; - } + return false; } - var isLocationInElementTag = tryMakeMarkupElement is not null && - (tryMakeMarkupElement.StartTag.Span.Contains(context.Location.AbsoluteIndex) || - tryMakeMarkupElement.EndTag.Span.Contains(context.Location.AbsoluteIndex)); + return startTag.Span.Contains(context.Location.AbsoluteIndex) || endTag.Span.Contains(context.Location.AbsoluteIndex); + } - var isLocationInTagHelperTag = tryMakeMarkupTagHelperElement is not null && - (tryMakeMarkupTagHelperElement.StartTag.Span.Contains(context.Location.AbsoluteIndex) || - tryMakeMarkupTagHelperElement.EndTag.Span.Contains(context.Location.AbsoluteIndex)); + private static (MarkupSyntaxNode? startTag, MarkupSyntaxNode? endTag) GetStartAndEndTag(SyntaxNode owner) + { + var potentialElement = owner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); - return isLocationInElementTag || isLocationInTagHelperTag; + return potentialElement switch + { + MarkupElementSyntax markupElement => (markupElement.StartTag, markupElement.EndTag), + MarkupTagHelperElementSyntax tagHelper => (tagHelper.StartTag, tagHelper.EndTag), + _ => (null, null) + }; } private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen(returnValue: true)] out string? @namespace) From 65daa58aae348c1099c3e13c85b2027e2e81ebf2 Mon Sep 17 00:00:00 2001 From: marcarro Date: Wed, 4 Sep 2024 21:37:08 -0700 Subject: [PATCH 27/29] Changed GetUsingDirectivesInRange to use a SyntaxNode instead of a SyntaxTree to scan for using directives --- .../Razor/ExtractToComponentCodeActionResolver.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 0f279b95bab..87acc874a34 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -170,6 +170,7 @@ internal sealed record SelectionAnalysisResult private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) { var syntaxTree = codeDocument.GetSyntaxTree(); + var root = syntaxTree.Root; var sourceText = codeDocument.Source.Text; var (startElementNode, endElementNode) = GetStartAndEndElements(sourceText, syntaxTree, actionParams); @@ -194,7 +195,7 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod } var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - var usingDirectives = GetUsingDirectivesInRange(syntaxTree, dependencyScanRoot, extractStart, extractEnd); + var usingDirectives = GetUsingDirectivesInRange(root, dependencyScanRoot, extractStart, extractEnd); var hasOtherIdentifiers = CheckHasOtherIdentifiers(dependencyScanRoot, extractStart, extractEnd); return new SelectionAnalysisResult @@ -419,11 +420,11 @@ private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) return node is MarkupElementSyntax or MarkupTagHelperElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); } - private static HashSet GetUsingDirectivesInRange(RazorSyntaxTree syntaxTree, SyntaxNode root, int extractStart, int extractEnd) + private static HashSet GetUsingDirectivesInRange(SyntaxNode generalRoot, SyntaxNode scanRoot, int extractStart, int extractEnd) { // The new component usings are going to be a subset of the usings in the source razor file. using var pooledStringArray = new PooledArrayBuilder(); - foreach (var node in syntaxTree.Root.DescendantNodes()) + foreach (var node in generalRoot.DescendantNodes()) { if (node.IsUsingDirective(out var children)) { @@ -465,7 +466,7 @@ private static HashSet GetUsingDirectivesInRange(RazorSyntaxTree syntaxT var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); // Only analyze nodes within the extract span - foreach (var node in root.DescendantNodes()) + foreach (var node in scanRoot.DescendantNodes()) { if (!extractSpan.Contains(node.Span)) { From 310da7d4a9f908135e62d4e0b96e45660fab0602 Mon Sep 17 00:00:00 2001 From: marcarro Date: Fri, 6 Sep 2024 17:08:12 -0700 Subject: [PATCH 28/29] Added more tests --- ...onEndToEndTest.ExtractToComponent.NetFx.cs | 423 +++++++++++++++++- 1 file changed, 414 insertions(+), 9 deletions(-) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs index 4ee660fc1d8..4a92da116b4 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; +using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; using Microsoft.CodeAnalysis.Razor.ProjectSystem; @@ -32,12 +33,16 @@ private ExtractToComponentCodeActionResolver[] CreateExtractComponentCodeActionR int? version = 1; projectManager.Setup(x => x.TryGetDocumentVersion(It.IsAny(), out version)).Returns(true); + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + return [ new ExtractToComponentCodeActionResolver( new GenerateMethodResolverDocumentContextFactory(filePath, codeDocument), // We can use the same factory here TestLanguageServerFeatureOptions.Instance, clientConnection, - projectManager.Object) + projectManager.Object, + mockTelemetry.Object) ]; } @@ -63,11 +68,14 @@ public async Task Handle_ExtractComponent_SingleElement_ReturnsResult()
"""; + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + await ValidateExtractComponentCodeActionAsync( input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -96,11 +104,14 @@ public async Task Handle_ExtractComponent_SiblingElement_ReturnsResult() """; + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + await ValidateExtractComponentCodeActionAsync( input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -131,11 +142,14 @@ public async Task Handle_ExtractComponent_StartNodeContainsEndNode_ReturnsResult """; + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + await ValidateExtractComponentCodeActionAsync( input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -166,11 +180,14 @@ public async Task Handle_ExtractComponent_EndNodeContainsStartNode_ReturnsResult """; + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + await ValidateExtractComponentCodeActionAsync( input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -197,11 +214,14 @@ public async Task Handle_ExtractComponent_IndentedNode_ReturnsResult() """; + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + await ValidateExtractComponentCodeActionAsync( input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -246,11 +266,14 @@ public async Task Handle_ExtractComponent_IndentedSiblingNodes_ReturnsResult() """; + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + await ValidateExtractComponentCodeActionAsync( input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -277,11 +300,14 @@ public async Task Handle_ExtractComponent_IndentedStartNodeContainsEndNode_Retur """; + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + await ValidateExtractComponentCodeActionAsync( input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -308,14 +334,393 @@ public async Task Handle_ExtractComponent_IndentedEndNodeContainsStartNode_Retur """; + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_SelectionStartsAtMarkup_EndsAtCodeBlock_ReturnsResult() + { + var input = """ + <[|div id="parent"> +
+
+
+

Deeply nested par

+
+
+
+ + + @code { + public int x = 7; + }|] + """; + + var expectedRazorComponent = """ +
+
+
+
+

Deeply nested par

+
+
+
+
+ + @code { + public int x = 7; + } + """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact(Skip = "Fails. Test environment does not provide a `RazorMetaCode` node after the right brace as expected.")] + public async Task Handle_ExtractComponent_SelectionStartsAtMarkup_EndsAtCodeBlock_WithTrailingContent_ReturnsResult() + { + var input = """ + <[|div id="parent"> +
+
+
+

Deeply nested par

+
+
+
+ + + @code { + public int x = 7; + }|] + +
+

After

+
+ """; + + var expectedRazorComponent = """ +
+
+
+
+

Deeply nested par

+
+
+
+
+ + @code { + public int x = 7; + } + """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_SelectionStartsAtIndentedMarkup_EndsAtCodeBlock_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|p>Deeply nested par

+
+
+
+
+ + @code { + public int x = 7; + }|] + """; + + var expectedRazorComponent = """ +
+
+
+
+

Deeply nested par

+
+
+
+
+ + @code { + public int x = 7; + } + """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact (Skip = "Fails. Test environment does not provide a `RazorMetaCode` node after the right brace as expected.")] + public async Task Handle_ExtractComponent_SelectionStartsAtIndentedMarkup_EndsAtCodeBlock_WithTrailingContent_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|p>Deeply nested par

+
+
+
+
+ + @code { + public int x = 7; + }|] + +
+

After

+
+ """; + + var expectedRazorComponent = """ +
+
+
+
+

Deeply nested par

+
+
+
+
+ + @code { + public int x = 7; + } + """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_SelectionStartsAtCodeBlock_EndsAtMarkup_ReturnsResult() + { + var input = """ + [|@code { + public int x = 7; + } + +
+
+
+
+

Deeply nested par

+
+
+
+ + """; + + var expectedRazorComponent = """ + @code { + public int x = 7; + } + +
+
+
+
+

Deeply nested par

+
+
+
+
+ """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_SelectionStartsAtCodeBlock_EndsAtIndentedMarkup_ReturnsResult() + { + var input = """ + [|@code { + public int x = 7; + } + +
+
+
+
+

Deeply nested par

+ +
+
+
+ """; + + var expectedRazorComponent = """ + @code { + public int x = 7; + } + +
+
+
+
+

Deeply nested par

+
+
+
+
+ """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_MixedContent_ReturnsResult() + { + var input = """ +
+

Title

+ <[|p>Some text

+ @{ + var x = 10; + } + @x|] +
Footer
+
+ """; + + var expectedRazorComponent = """ +

Some text

+ @{ + var x = 10; + } + @x + """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + await ValidateExtractComponentCodeActionAsync( input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } + [Fact] + public async Task Handle_ExtractComponent_WithComments_ReturnsResult() + { + var input = """ +
+ + <[|h1>Title + +

Some text

|] + +
+ """; + + var expectedRazorComponent = """ +

Title

+ +

Some text

+ """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + [Fact] + public async Task Handle_ExtractComponent_WithAttributes_ReturnsResult() + { + var input = """ +
+ <[|h1 class="title" id="main-title">Title +

Some text

|] +
+ """; + + var expectedRazorComponent = """ +

Title

+

Some text

+ """; + + var mockTelemetry = new StrictMock(); + mockTelemetry.Setup(t => t.BeginBlock(It.IsAny(), It.IsAny(), It.IsAny())).Returns(TelemetryScope.Null); + + await ValidateExtractComponentCodeActionAsync( + input, + expectedRazorComponent, + ExtractToComponentTitle, + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory, mockTelemetry.Object)], + codeActionResolversCreator: CreateExtractComponentCodeActionResolver); + } + + // NOTE: Tests for component extraction with @usings are needed, as well as for method and attribute promotion. + private async Task ValidateExtractComponentCodeActionAsync( string input, string? expected, From 1393e15a03426d71e26e81600a5253863a66210e Mon Sep 17 00:00:00 2001 From: marcarro Date: Fri, 6 Sep 2024 17:09:16 -0700 Subject: [PATCH 29/29] Final refactoring: moved selection validation methods to resolver to reduce duplicate work --- .../ExtractToComponentCodeActionParams.cs | 22 +- .../ExtractToComponentCodeActionProvider.cs | 499 ++++++++++++++- .../ExtractToComponentCodeActionResolver.cs | 587 ++++-------------- 3 files changed, 609 insertions(+), 499 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs index 089d08e3b3d..42de93fb9f2 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; @@ -15,14 +16,23 @@ internal sealed class ExtractToComponentCodeActionParams [JsonPropertyName("uri")] public required Uri Uri { get; set; } - [JsonPropertyName("selectStart")] - public required Position SelectStart { get; set; } + [JsonPropertyName("extractStart")] + public required int ExtractStart { get; set; } - [JsonPropertyName("selectEnd")] - public required Position SelectEnd { get; set; } + [JsonPropertyName("extractEnd")] + public required int ExtractEnd { get; set; } - [JsonPropertyName("absoluteIndex")] - public required int AbsoluteIndex { get; set; } + [JsonPropertyName("hasEventHandlerOrExpression")] + public required bool HasEventHandlerOrExpression { get; set; } + + [JsonPropertyName("hasAtCodeBlock")] + public required bool HasAtCodeBlock { get; set; } + + [JsonPropertyName("usingDirectives")] + public required string[] UsingDirectives { get; set; } + + [JsonPropertyName("dedentWhitespaceString")] + public required string DedentWhitespaceString { get; set; } [JsonPropertyName("namespace")] public required string Namespace { get; set; } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index 905ea167653..615d0ebeafd 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -1,18 +1,22 @@ // 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.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Threading; using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; @@ -38,8 +42,8 @@ public Task> ProvideAsync(RazorCodeAct return SpecializedTasks.EmptyImmutableArray(); } - var syntaxTree = context.CodeDocument.GetSyntaxTree(); - if (!IsValidSelection(context, syntaxTree)) + var selectionAnalysisResult = TryAnalyzeSelection(context); + if (!selectionAnalysisResult.Success) { return SpecializedTasks.EmptyImmutableArray(); } @@ -47,9 +51,12 @@ public Task> ProvideAsync(RazorCodeAct var actionParams = new ExtractToComponentCodeActionParams { Uri = context.Request.TextDocument.Uri, - SelectStart = context.Request.Range.Start, - SelectEnd = context.Request.Range.End, - AbsoluteIndex = context.Location.AbsoluteIndex, + ExtractStart = selectionAnalysisResult.ExtractStart, + ExtractEnd = selectionAnalysisResult.ExtractEnd, + HasEventHandlerOrExpression = selectionAnalysisResult.HasEventHandlerOrExpression, + HasAtCodeBlock = selectionAnalysisResult.HasAtCodeBlock, + UsingDirectives = selectionAnalysisResult.UsingDirectives ?? Array.Empty(), + DedentWhitespaceString = selectionAnalysisResult.DedentWhitespaceString ?? string.Empty, Namespace = @namespace, }; @@ -75,29 +82,208 @@ private static bool IsValidContext(RazorCodeActionContext context) context.CodeDocument.GetSyntaxTree() is not null; } - private bool IsValidSelection(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) + internal sealed record SelectionAnalysisResult { - var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); - if (owner is null) + public required bool Success; + public int ExtractStart; + public int ExtractEnd; + public bool HasAtCodeBlock; + public bool HasEventHandlerOrExpression; + public string[]? UsingDirectives; + public string? DedentWhitespaceString; + } + + private SelectionAnalysisResult TryAnalyzeSelection(RazorCodeActionContext context) + { + var treeRoot = context.CodeDocument.GetSyntaxTree().Root; + var sourceText = context.SourceText; + + var startAbsoluteIndex = context.Location.AbsoluteIndex; + var endAbsoluteIndex = sourceText.GetRequiredAbsoluteIndex(context.Request.Range.End); + + var startOwner = treeRoot.FindInnermostNode(startAbsoluteIndex, includeWhitespace: true); + var endOwner = treeRoot.FindInnermostNode(endAbsoluteIndex, includeWhitespace: true); + + if (startOwner is null || endOwner is null) { _logger.LogWarning($"Owner should never be null."); - return false; + return new SelectionAnalysisResult { Success = false }; + } + + (startOwner, var startElementNode, var hasAtCodeBlock) = AnalyzeSelectionStart(startOwner); + (endOwner, var endElementNode, hasAtCodeBlock) = AnalyzeSelectionEnd(endOwner); + + // At this point, at least one end of the selection must either be a valid `MarkupElement` or `MarkupTagHelperElement` + var isValidStartElementNode = startElementNode is not null && + !startElementNode.GetDiagnostics().Any(d => d.Severity == RazorDiagnosticSeverity.Error); + + var isValidEndElementNode = endElementNode is not null && + !endElementNode.GetDiagnostics().Any(d => d.Severity == RazorDiagnosticSeverity.Error); + + var selectionEndsAreValid = IsOnMarkupTag(startAbsoluteIndex, startOwner) || IsOnMarkupTag(endAbsoluteIndex, endOwner); + + if (!selectionEndsAreValid || !(isValidStartElementNode || isValidEndElementNode)) + { + return new SelectionAnalysisResult { Success = false }; + } + + // Use element nodes if found, otherwise use the original owners (either could be CSHarpCodeBlockSyntax) + startOwner = startElementNode is not null ? startElementNode : startOwner; + endOwner = endElementNode is not null ? endElementNode : endOwner; + + // Process the selection to determine exact extraction bounds + // Note: startOwner and endOwner are modified in-place to correct for sibling selection and adjacent scenarios, if necessary + if (!TryProcessSelection(ref startOwner, ref endOwner)) + { + return new SelectionAnalysisResult { Success = false }; + } + + // Check if there are any components inside the selection that require using statements in the new component. + // In all scenarios, @usings for a new component are a subset of @usings in the source file. + var scanRoot = FindNearestCommonAncestor(startOwner, endOwner) ?? treeRoot; // Fallback to the tree root if no common ancestor is found + + int extractStart = startOwner.Span.Start, extractEnd = endOwner.Span.End; + // Also check for event handler and data binding identifiers. + var (hasOtherIdentifiers, usingDirectivesInRange) = GetUsingsIdentifiersInRange(treeRoot, scanRoot, extractStart, extractEnd); + + // Get dedent whitespace + // The amount of whitespace to dedent is the smaller of the whitespaces before the start element and before the end MarkupElement if they exist. + // Another way to think about it is that we want to dedent the selection to the smallest, nonempty common whitespace prefix, if it exists. + + // For example: + //
+ //
+ // <[|p>Some text

+ //
+ // + // The dedent whitespace should be based on the end MarkupElement (
), not the start MarkupElement (

). + var dedentWhitespace = string.Empty; + if (isValidStartElementNode && + startOwner?.TryGetPreviousSibling(out var whitespaceNode) == true && + whitespaceNode.ContainsOnlyWhitespace()) + { + var startWhitespace = whitespaceNode.ToFullString(); + startWhitespace = startWhitespace.Replace("\r", string.Empty).Replace("\n", string.Empty); + + if (!startWhitespace.IsNullOrEmpty()) + { + dedentWhitespace = startWhitespace; + } + } + + if (isValidEndElementNode && + endOwner?.TryGetPreviousSibling(out whitespaceNode) == true && + whitespaceNode.ContainsOnlyWhitespace()) + { + var endDedentWhitespace = whitespaceNode.ToFullString(); + endDedentWhitespace = endDedentWhitespace.Replace("\r", string.Empty).Replace("\n", string.Empty); + + if (!endDedentWhitespace.IsNullOrEmpty() && + (dedentWhitespace.IsNullOrEmpty() || + endDedentWhitespace.Length < dedentWhitespace.Length)) + { + dedentWhitespace = endDedentWhitespace; + } + } + + return new SelectionAnalysisResult + { + Success = true, + ExtractStart = extractStart, + ExtractEnd = extractEnd, + HasAtCodeBlock = hasAtCodeBlock, + HasEventHandlerOrExpression = hasOtherIdentifiers, + UsingDirectives = usingDirectivesInRange.ToArray(), + DedentWhitespaceString = dedentWhitespace + }; + } + + private static (SyntaxNode owner, MarkupSyntaxNode? startElementNode, bool hasAtCodeBlock) AnalyzeSelectionStart(SyntaxNode startOwner) + { + var elementNode = startOwner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); + var hasAtCodeBlock = false; + + if (elementNode is null) + { + var codeBlock = startOwner.FirstAncestorOrSelf(); + if (codeBlock is not null) + { + hasAtCodeBlock = true; + startOwner = codeBlock; + } + } + + return (startOwner, elementNode, hasAtCodeBlock); + } + + private static (SyntaxNode owner, MarkupSyntaxNode? endElementNode, bool hasAtCodeBlock) AnalyzeSelectionEnd(SyntaxNode endOwner) + { + var elementNode = endOwner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); + var hasAtCodeBlock = false; + + // Case 1: Selection ends at the "edge" of a tag (i.e. immediately after the ">") + if (endOwner is MarkupTextLiteralSyntax && endOwner.ContainsOnlyWhitespace() && endOwner.TryGetPreviousSibling(out var previousMarkupSibling)) + { + elementNode = previousMarkupSibling.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); } - var startElementNode = owner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); - return startElementNode is not null && !startElementNode.GetDiagnostics().Any(d => d.Severity == RazorDiagnosticSeverity.Error) && IsInsideMarkupTag(context, owner); + // Case 2: Selection ends at the end of a code block (i.e. immediately after the "}") + if (ShouldAdjustEndNode(endOwner, elementNode)) + { + var adjustedNode = AdjustEndNode(endOwner); + if (adjustedNode is CSharpCodeBlockSyntax) + { + hasAtCodeBlock = true; + endOwner = adjustedNode; + } + } + + return (endOwner, elementNode, hasAtCodeBlock); } - private static bool IsInsideMarkupTag(RazorCodeActionContext context, SyntaxNode owner) + private static bool ShouldAdjustEndNode(SyntaxNode endOwner, MarkupSyntaxNode? elementNode) + // When the user selects a code block and the selection ends immediately after the right brace: + // If there is no more text content after the right brace, 'endOwner' will be a MarkupTextLiteral with a Marker token inside. + // If there is content after the right brace (including even a NewLine), 'endOwner' will be a 'RazorMetaCode' with a NewLine token. + + // If `endOwner` is `MarkupTextLiteral`, its previous sibling will be the `CSharpCodeBlock` itself. + // MarkupBlock -> (CSharpCodeBlock, MarkupTextLiteral -> Marker) + + // If `endOwner` is 'RazorMetaCode`, its previous sibling will be the `RazorDirective` immediately inside `CSharpCodeBlock`. + // MarkupBlock -> CSharpCodeBlock -> (RazorDirective, RazorMetaCode) + + // In both cases above, the desired end node is the `CSharpCodeBlock` itself. + // For the first case, it's previous sibling of `MarkupTextLiteral` + // For the second case, it's the parent of both 'RazorDirective' and its previous sibling. + => elementNode is null && ( + (endOwner is MarkupTextLiteralSyntax textLiteral && textLiteral.LiteralTokens.Any(token => token.Kind is SyntaxKind.Marker)) || + (endOwner is RazorMetaCodeSyntax metaCode && metaCode.ContainsOnlyWhitespace()) + ); + + private static SyntaxNode AdjustEndNode(SyntaxNode endOwner) + { + if (endOwner.TryGetPreviousSibling(out var previousSibling)) + { + return previousSibling.FirstAncestorOrSelf() ?? endOwner; + } + return endOwner; + } + + private static bool IsOnMarkupTag(int absoluteIndex, SyntaxNode owner) { var (startTag, endTag) = GetStartAndEndTag(owner); - if (startTag is null || endTag is null) + if (startTag is null) { return false; } - return startTag.Span.Contains(context.Location.AbsoluteIndex) || endTag.Span.Contains(context.Location.AbsoluteIndex); + endTag ??= startTag; // Self-closing tag + + var isOnStartTag = startTag.Span.Start <= absoluteIndex && absoluteIndex <= startTag.Span.End; + var isOnEndTag = endTag.Span.Start <= absoluteIndex && absoluteIndex <= endTag.Span.End; + + return isOnStartTag || isOnEndTag; } private static (MarkupSyntaxNode? startTag, MarkupSyntaxNode? endTag) GetStartAndEndTag(SyntaxNode owner) @@ -119,5 +305,290 @@ private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen // good namespace to extract to => codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out @namespace); + ///

+ /// Processes a selection, modifying and in place to correct selection bounds. + /// + /// The starting element of the selection. + /// The ending element of the selection + /// true if the selection was successfully processed; otherwise, false. + private static bool TryProcessSelection( + ref SyntaxNode startNode, + ref SyntaxNode endNode) + { + if (ReferenceEquals(startNode, endNode)) + { + return true; + } + + // If the start node contains the end node (or vice versa), we can extract the entire range + if (startNode.Span.Contains(endNode.Span)) + { + endNode = startNode; + return true; + } + + if (endNode.Span.Contains(startNode.Span)) + { + startNode = endNode; + return true; + } + + // If the start element is not an ancestor of the end element (or vice versa), we need to find a common parent + // This conditional handles cases where the user's selection spans across different levels of the DOM. + // For example: + //
+ // {|result: + // {|selection:

Some text

+ //
+ // + //

More text

+ //
+ // |}|} + // + //
+ // In this case, we need to find the smallest set of complete elements that covers the entire selection. + + // Find the closest containing sibling pair that encompasses both the start and end elements + var (selectStart, selectEnd) = FindContainingSiblingPair(startNode, endNode); + if (selectStart is not null && selectEnd is not null) + { + startNode = selectStart; + endNode = selectEnd; + return true; + } + + // Note: If we don't find a valid pair, we keep the original extraction range + return true; + + } + + /// + /// Finds the smallest set of sibling nodes that contain both the start and end nodes. + /// This is useful for determining the scope of a selection that spans across different levels of the syntax tree. + /// + /// The node where the selection starts. + /// The node where the selection ends. + /// A tuple containing the start and end nodes of the containing sibling pair. + private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) + { + // Find the lowest common ancestor of both nodes + var nearestCommonAncestor = FindNearestCommonAncestor(startNode, endNode); + if (nearestCommonAncestor is null) + { + return (null, null); + } + + SyntaxNode? startContainingNode = null; + SyntaxNode? endContainingNode = null; + + // Pre-calculate the spans for comparison + var startSpan = startNode.Span; + var endSpan = endNode.Span; + + var startIsCodeBlock = startNode is CSharpCodeBlockSyntax; + var endIsCodeBlock = endNode is CSharpCodeBlockSyntax; + + foreach (var child in nearestCommonAncestor.ChildNodes()) + { + var childSpan = child.Span; + + if (startContainingNode is null && + childSpan.Contains(startSpan) && + ( + (startIsCodeBlock && child is CSharpCodeBlockSyntax) || + (!startIsCodeBlock && (child is MarkupElementSyntax or MarkupTagHelperElementSyntax)) + )) + { + startContainingNode = child; + } + + if (endContainingNode is null && + childSpan.Contains(endSpan) && + ( + (endIsCodeBlock && child is CSharpCodeBlockSyntax) || + (!endIsCodeBlock && (child is MarkupElementSyntax or MarkupTagHelperElementSyntax)) + )) + { + endContainingNode = child; + } + + if (startContainingNode is not null && endContainingNode is not null) + { + break; + } + } + + return (startContainingNode, endContainingNode); + } + + private static SyntaxNode? FindNearestCommonAncestor(SyntaxNode node1, SyntaxNode node2) + { + for (var current = node1; current is not null; current = current.Parent) + { + if (IsValidAncestorNode(current) && current.Span.Contains(node2.Span)) + { + return current; + } + } + + return null; + } + + private static bool IsValidAncestorNode(SyntaxNode node) => node is MarkupElementSyntax or MarkupTagHelperElementSyntax or MarkupBlockSyntax; + + private static (bool hasOtherIdentifiers, HashSet usingDirectives) GetUsingsIdentifiersInRange(SyntaxNode generalRoot, SyntaxNode scanRoot, int extractStart, int extractEnd) + { + var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); + var hasOtherIdentifiers = false; + var usings = new HashSet(); + + // Get all using directives from the general root + var usingsInSourceRazor = GetAllUsingDirectives(generalRoot); + + foreach (var node in scanRoot.DescendantNodes()) + { + if (!extractSpan.Contains(node.Span)) + { + continue; + } + + if (!hasOtherIdentifiers) + { + hasOtherIdentifiers = CheckNodeForIdentifiers(node); + } + + if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) + { + AddUsingFromTagHelperInfo(tagHelperInfo, usings, usingsInSourceRazor); + } + } + + return (hasOtherIdentifiers, usings); + } + + private static string[] GetAllUsingDirectives(SyntaxNode generalRoot) + { + using var pooledStringArray = new PooledArrayBuilder(); + foreach (var node in generalRoot.DescendantNodes()) + { + if (node.IsUsingDirective(out var children)) + { + var sb = new StringBuilder(); + var identifierFound = false; + var lastIdentifierIndex = -1; + + // First pass: find the last identifier + for (var i = 0; i < children.Count; i++) + { + if (children[i] is Language.Syntax.SyntaxToken token && token.Kind == Language.SyntaxKind.Identifier) + { + lastIdentifierIndex = i; + } + } + + // Second pass: build the string + for (var i = 0; i <= lastIdentifierIndex; i++) + { + var child = children[i]; + if (child is Language.Syntax.SyntaxToken tkn && tkn.Kind == Language.SyntaxKind.Identifier) + { + identifierFound = true; + } + + if (identifierFound) + { + var token = child as Language.Syntax.SyntaxToken; + sb.Append(token?.Content); + } + } + + pooledStringArray.Add(sb.ToString()); + } + } + + return pooledStringArray.ToArray(); + } + + private static bool CheckNodeForIdentifiers(SyntaxNode node) + { + // This method checks for identifiers in event handlers and data bindings. + + // Even if the user doesn't reference any fields or methods from an @code block in their selection, + // event handlers and data binding references are still expected to be passed in via parameters in the new component. + // Hence, the call to Roslyn to get symbolic info must still be made if these are present in the extracted range. + + // An assumption I'm making that might be wrong: + // CSharpImplicitExpressionBodySyntax, CSharpExplicitExpressionBodySyntax, and MarkupTagHelperDirectiveAttributeSyntax + // nodes contain only one child of type CSharpExpressionLiteralSyntax + + // For MarkupTagHelperDirectiveAttributeSyntax, the syntax tree seems to show only one child of the contained CSharpExpressionLiteral as Text, + // so it might not be worth it to check for identifiers, but only if the above is true in all cases. + + if (node is CSharpImplicitExpressionBodySyntax or CSharpExplicitExpressionBodySyntax) + { + var expressionLiteral = node.DescendantNodes().OfType().SingleOrDefault(); + if (expressionLiteral is not null) + { + foreach (var token in expressionLiteral.LiteralTokens) + { + if (token.Kind is Language.SyntaxKind.Identifier) + { + return true; + } + } + } + } + else if (node is MarkupTagHelperDirectiveAttributeSyntax directiveAttribute) + { + var attributeDelegate = directiveAttribute.DescendantNodes().OfType().SingleOrDefault(); + if (attributeDelegate is not null) + { + if (attributeDelegate.LiteralTokens.FirstOrDefault() is Language.Syntax.SyntaxToken { Kind: Language.SyntaxKind.Text }) + { + return true; + } + } + } + + return false; + } + + private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet usings, string[] usingsInSourceRazor) + { + foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) + { + if (descriptor is null) + { + continue; + } + + var typeNamespace = descriptor.GetTypeNamespace(); + + // Since the using directive at the top of the file may be relative and not absolute, + // we need to generate all possible partial namespaces from `typeNamespace`. + + // Potentially, the alternative could be to ask if the using namespace at the top is a substring of `typeNamespace`. + // The only potential edge case is if there are very similar namespaces where one + // is a substring of the other, but they're actually different (e.g., "My.App" and "My.Apple"). + + // Generate all possible partial namespaces from `typeNamespace`, from least to most specific + // (assuming that the user writes absolute `using` namespaces most of the time) + + // This is a bit inefficient because at most 'k' string operations are performed (k = parts in the namespace), + // for each potential using directive. + + var parts = typeNamespace.Split('.'); + for (var i = 0; i < parts.Length; i++) + { + var partialNamespace = string.Join(".", parts.Skip(i)); + + if (usingsInSourceRazor.Contains(partialNamespace)) + { + usings.Add(partialNamespace); + break; + } + } + } + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 87acc874a34..45ff6507749 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; @@ -15,6 +16,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.Diagnostics; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor; @@ -34,17 +36,22 @@ internal sealed class ExtractToComponentCodeActionResolver( IDocumentContextFactory documentContextFactory, LanguageServerFeatureOptions languageServerFeatureOptions, IClientConnection clientConnection, - IDocumentVersionCache documentVersionCache) : IRazorCodeActionResolver + IDocumentVersionCache documentVersionCache, + ITelemetryReporter telemetryReporter) : IRazorCodeActionResolver { private readonly IDocumentContextFactory _documentContextFactory = documentContextFactory; private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions; private readonly IClientConnection _clientConnection = clientConnection; private readonly IDocumentVersionCache _documentVersionCache = documentVersionCache; + private readonly ITelemetryReporter _telemetryReporter = telemetryReporter; public string Action => LanguageServerConstants.CodeActions.ExtractToComponentAction; public async Task ResolveAsync(JsonElement data, CancellationToken cancellationToken) { + var telemetryDidSucceed = false; + using var _ = _telemetryReporter.BeginBlock("extractToComponentResolver", Severity.Normal, new Property("didSucceed", telemetryDidSucceed)); + var actionParams = data.Deserialize(); if (actionParams is null) { @@ -57,32 +64,10 @@ internal sealed class ExtractToComponentCodeActionResolver( } var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); - var syntaxTree = codeDocument.GetSyntaxTree(); - - var selectionAnalysis = TryAnalyzeSelection(codeDocument, actionParams); - if (!selectionAnalysis.Success) - { - return null; - } - - // For the purposes of determining the indentation of the extracted code, get the whitespace before the start of the selection. - var whitespaceReferenceOwner = codeDocument.GetSyntaxTree().Root.FindInnermostNode(selectionAnalysis.ExtractStart, includeWhitespace: true).AssumeNotNull(); - var whitespaceReferenceNode = whitespaceReferenceOwner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax).AssumeNotNull(); - var whitespace = string.Empty; - if (whitespaceReferenceNode.TryGetPreviousSibling(out var startPreviousSibling) && startPreviousSibling.ContainsOnlyWhitespace()) - { - // Get the whitespace substring so we know how much to dedent the extracted code. Remove any carriage return and newline escape characters. - whitespace = startPreviousSibling.ToFullString(); - whitespace = whitespace.Replace("\r", string.Empty).Replace("\n", string.Empty); - } + var startLinePosition = codeDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractStart); + var endLinePosition = codeDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractEnd); - var start = codeDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractStart); - var end = codeDocument.Source.Text.Lines.GetLinePosition(selectionAnalysis.ExtractEnd); - var removeRange = new Range - { - Start = new Position(start.Line, start.Character), - End = new Position(end.Line, end.Character) - }; + var removeRange = VsLspFactory.CreateRange(startLinePosition, endLinePosition); var path = FilePathNormalizer.Normalize(actionParams.Uri.GetAbsoluteOrUNCPath()); var directoryName = Path.GetDirectoryName(path).AssumeNotNull(); @@ -103,12 +88,10 @@ internal sealed class ExtractToComponentCodeActionResolver( var componentName = Path.GetFileNameWithoutExtension(componentPath); var newComponentResult = await GenerateNewComponentAsync( - selectionAnalysis, + actionParams, codeDocument, - actionParams.Uri, documentContext, removeRange, - whitespace, cancellationToken).ConfigureAwait(false); if (newComponentResult is null) @@ -151,439 +134,22 @@ internal sealed class ExtractToComponentCodeActionResolver( } }; + telemetryDidSucceed = true; return new WorkspaceEdit { DocumentChanges = documentChanges, }; } - internal sealed record SelectionAnalysisResult - { - public required bool Success; - public int ExtractStart; - public int ExtractEnd; - public bool HasAtCodeBlock; - public bool HasEventHandlerOrExpression; - public HashSet? UsingDirectives; - } - - private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) - { - var syntaxTree = codeDocument.GetSyntaxTree(); - var root = syntaxTree.Root; - var sourceText = codeDocument.Source.Text; - - var (startElementNode, endElementNode) = GetStartAndEndElements(sourceText, syntaxTree, actionParams); - if (startElementNode is null) - { - return new SelectionAnalysisResult { Success = false }; - } - - endElementNode ??= startElementNode; - - var success = TryProcessSelection(startElementNode, - endElementNode, - codeDocument, - actionParams, - out var extractStart, - out var extractEnd, - out var hasAtCodeBlock); - - if (!success) - { - return new SelectionAnalysisResult { Success = false }; - } - - var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - var usingDirectives = GetUsingDirectivesInRange(root, dependencyScanRoot, extractStart, extractEnd); - var hasOtherIdentifiers = CheckHasOtherIdentifiers(dependencyScanRoot, extractStart, extractEnd); - - return new SelectionAnalysisResult - { - Success = success, - ExtractStart = extractStart, - ExtractEnd = extractEnd, - HasAtCodeBlock = hasAtCodeBlock, - HasEventHandlerOrExpression = hasOtherIdentifiers, - UsingDirectives = usingDirectives, - }; - } - - private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndElements(SourceText sourceText, RazorSyntaxTree syntaxTree, ExtractToComponentCodeActionParams actionParams) - { - var owner = syntaxTree.Root.FindInnermostNode(actionParams.AbsoluteIndex, includeWhitespace: true); - if (owner is null) - { - return (null, null); - } - - var startElementNode = owner.FirstAncestorOrSelf(node => node is MarkupTagHelperElementSyntax or MarkupElementSyntax); - if (startElementNode is null) - { - return (null, null); - } - - var endElementNode = GetEndElementNode(sourceText, syntaxTree, actionParams); - - return (startElementNode, endElementNode); - } - - private static MarkupSyntaxNode? GetEndElementNode(SourceText sourceText, RazorSyntaxTree syntaxTree, ExtractToComponentCodeActionParams actionParams) - { - var selectionStart = actionParams.SelectStart; - var selectionEnd = actionParams.SelectEnd; - - if (selectionStart == selectionEnd) - { - return null; - } - - var endAbsoluteIndex = sourceText.GetRequiredAbsoluteIndex(selectionEnd); - var endOwner = syntaxTree.Root.FindInnermostNode(endAbsoluteIndex, true); - if (endOwner is null) - { - return null; - } - - // Correct selection to include the current node if the selection ends at the "edge" (i.e. immediately after the ">") of a tag. - if (endOwner is MarkupTextLiteralSyntax && endOwner.ContainsOnlyWhitespace() && endOwner.TryGetPreviousSibling(out var previousSibling)) - { - endOwner = previousSibling; - } - - return endOwner.FirstAncestorOrSelf(node => node is MarkupTagHelperElementSyntax or MarkupElementSyntax); - } - - /// - /// Processes a selection, providing the start and end of the extraction range if successful. - /// - /// The starting element of the selection. - /// The ending element of the selection - /// The code document containing the selection. - /// The parameters for the extraction action - /// The start of the extraction range. - /// The end of the extraction range - /// Whether the selection has a @code block - /// true if the selection was successfully processed; otherwise, false. - private static bool TryProcessSelection( - MarkupSyntaxNode startElementNode, - MarkupSyntaxNode endElementNode, - RazorCodeDocument codeDocument, - ExtractToComponentCodeActionParams actionParams, - out int extractStart, - out int extractEnd, - out bool hasCodeBlock) - { - extractStart = startElementNode.Span.Start; - extractEnd = endElementNode.Span.End; - hasCodeBlock = false; - - // Check if it's a multi-point selection - if (actionParams.SelectStart == actionParams.SelectEnd) - { - return true; - } - - // If the start node contains the end node (or vice versa), we can extract the entire range - if (endElementNode.Ancestors().Contains(startElementNode)) - { - extractEnd = startElementNode.Span.End; - return true; - } - - if (startElementNode.Ancestors().Contains(endElementNode)) - { - extractStart = endElementNode.Span.Start; - return true; - } - - // If the start element is not an ancestor of the end element (or vice versa), we need to find a common parent - // This conditional handles cases where the user's selection spans across different levels of the DOM. - // For example: - //
- // {|result: - // {|selection:

Some text

- //
- // - //

More text

- //
- // |}|} - // - //
- // In this case, we need to find the smallest set of complete elements that covers the entire selection. - if (startElementNode != endElementNode) - { - // Find the closest containing sibling pair that encompasses both the start and end elements - var (selectStart, selectEnd) = FindContainingSiblingPair(startElementNode, endElementNode); - if (selectStart is not null && selectEnd is not null) - { - extractStart = selectStart.Span.Start; - extractEnd = selectEnd.Span.End; - return true; - } - - // Note: If we don't find a valid pair, we keep the original extraction range - return true; - } - - var endLocation = codeDocument.Source.Text.GetRequiredAbsoluteIndex(actionParams.SelectEnd); - - var endOwner = codeDocument.GetSyntaxTree().Root.FindInnermostNode(endLocation, true); - var endCodeBlock = endOwner?.FirstAncestorOrSelf(); - if (endOwner is not null && endOwner.TryGetPreviousSibling(out var previousSibling)) - { - endCodeBlock = previousSibling.FirstAncestorOrSelf(); - } - - if (endCodeBlock is not null) - { - hasCodeBlock = true; - var (withCodeBlockStart, withCodeBlockEnd) = FindContainingSiblingPair(startElementNode, endCodeBlock); - extractStart = withCodeBlockStart?.Span.Start ?? extractStart; - extractEnd = withCodeBlockEnd?.Span.End ?? extractEnd; - } - - return true; - } - - /// - /// Finds the smallest set of sibling nodes that contain both the start and end nodes. - /// This is useful for determining the scope of a selection that spans across different levels of the syntax tree. - /// - /// The node where the selection starts. - /// The node where the selection ends. - /// A tuple containing the start and end nodes of the containing sibling pair. - private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) - { - // Find the lowest common ancestor of both nodes - var nearestCommonAncestor = FindNearestCommonAncestor(startNode, endNode); - if (nearestCommonAncestor is null) - { - return (null, null); - } - - SyntaxNode? startContainingNode = null; - SyntaxNode? endContainingNode = null; - - // Pre-calculate the spans for comparison - var startSpan = startNode.Span; - var endSpan = endNode.Span; - - var endIsCodeBlock = endNode is CSharpCodeBlockSyntax; - - foreach (var child in nearestCommonAncestor.ChildNodes()) - { - if (!IsValidNode(child, endIsCodeBlock)) - { - continue; - } - - var childSpan = child.Span; - if (startContainingNode is null && childSpan.Contains(startSpan)) - { - startContainingNode = child; - } - - // Check if this child contains the end node - if (childSpan.Contains(endSpan)) - { - endContainingNode = child; - } - - // If we've found both containing nodes, we can stop searching - if (startContainingNode is not null && endContainingNode is not null) - { - break; - } - } - - return (startContainingNode, endContainingNode); - } - - private static SyntaxNode? FindNearestCommonAncestor(SyntaxNode node1, SyntaxNode node2) - { - for (var current = node1; current is not null; current = current.Parent) - { - if (IsValidAncestorNode(current) && current.Span.Contains(node2.Span)) - { - return current; - } - } - - return null; - } - - private static bool IsValidAncestorNode(SyntaxNode node) => node is MarkupElementSyntax or MarkupTagHelperElementSyntax or MarkupBlockSyntax; - - private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) - { - return node is MarkupElementSyntax or MarkupTagHelperElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); - } - - private static HashSet GetUsingDirectivesInRange(SyntaxNode generalRoot, SyntaxNode scanRoot, int extractStart, int extractEnd) - { - // The new component usings are going to be a subset of the usings in the source razor file. - using var pooledStringArray = new PooledArrayBuilder(); - foreach (var node in generalRoot.DescendantNodes()) - { - if (node.IsUsingDirective(out var children)) - { - var sb = new StringBuilder(); - var identifierFound = false; - var lastIdentifierIndex = -1; - - // First pass: find the last identifier - for (var i = 0; i < children.Count; i++) - { - if (children[i] is Language.Syntax.SyntaxToken token && token.Kind == Language.SyntaxKind.Identifier) - { - lastIdentifierIndex = i; - } - } - - // Second pass: build the string - for (var i = 0; i <= lastIdentifierIndex; i++) - { - var child = children[i]; - if (child is Language.Syntax.SyntaxToken tkn && tkn.Kind == Language.SyntaxKind.Identifier) - { - identifierFound = true; - } - if (identifierFound) - { - var token = child as Language.Syntax.SyntaxToken; - sb.Append(token?.Content); - } - } - - pooledStringArray.Add(sb.ToString()); - } - } - - var usingsInSourceRazor = pooledStringArray.ToArray(); - - var usings = new HashSet(); - var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); - - // Only analyze nodes within the extract span - foreach (var node in scanRoot.DescendantNodes()) - { - if (!extractSpan.Contains(node.Span)) - { - continue; - } - - if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) - { - AddUsingFromTagHelperInfo(tagHelperInfo, usings, usingsInSourceRazor); - } - } - - return usings; - } - - private static bool CheckHasOtherIdentifiers(SyntaxNode root, int extractStart, int extractEnd) - { - var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); - - foreach (var node in root.DescendantNodes()) - { - if (!extractSpan.Contains(node.Span)) - { - continue; - } - - // An assumption I'm making that might be wrong: - // CSharpImplicitExpressionBodySyntax, CSharpExplicitExpressionBodySyntax, and MarkupTagHelperDirectiveAttributeSyntax - // nodes contain only one child of type CSharpExpressionLiteralSyntax - - // For MarkupTagHelperDirectiveAttributeSyntax, the syntax tree seems to show only one child of the contained CSharpExpressionLiteral as Text, - // so it might not be worth it to check for identifiers, but only if the above is true in all cases. - - if (node is CSharpImplicitExpressionBodySyntax or CSharpExplicitExpressionBodySyntax) - { - - var expressionLiteral = node.DescendantNodes().OfType().SingleOrDefault(); - if (expressionLiteral is null) - { - continue; - } - - foreach (var token in expressionLiteral.LiteralTokens) - { - if (token.Kind is Language.SyntaxKind.Identifier) - { - return true; - } - } - } - else if (node is MarkupTagHelperDirectiveAttributeSyntax directiveAttribute) - { - var attributeDelegate = directiveAttribute.DescendantNodes().OfType().SingleOrDefault(); - if (attributeDelegate is null) - { - continue; - } - - if (attributeDelegate.LiteralTokens.FirstOrDefault() is Language.Syntax.SyntaxToken { Kind: Language.SyntaxKind.Text }) - { - return true; - } - } - } - - return false; - } - - private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet usings, string[] usingsInSourceRazor) - { - foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) - { - if (descriptor is null) - { - continue; - } - - var typeNamespace = descriptor.GetTypeNamespace(); - - // Since the using directive at the top of the file may be relative and not absolute, - // we need to generate all possible partial namespaces from `typeNamespace`. - - // Potentially, the alternative could be to ask if the using namespace at the top is a substring of `typeNamespace`. - // The only potential edge case is if there are very similar namespaces where one - // is a substring of the other, but they're actually different (e.g., "My.App" and "My.Apple"). - - // Generate all possible partial namespaces from `typeNamespace`, from least to most specific - // (assuming that the user writes absolute `using` namespaces most of the time) - - // This is a bit inefficient because at most 'k' string operations are performed (k = parts in the namespace), - // for each potential using directive. - - var parts = typeNamespace.Split('.'); - for (var i = 0; i < parts.Length; i++) - { - var partialNamespace = string.Join(".", parts.Skip(i)); - - if (usingsInSourceRazor.Contains(partialNamespace)) - { - usings.Add(partialNamespace); - break; - } - } - } - } - /// /// Generates a new Razor component based on the selected content from existing markup. /// This method handles the extraction of code, processing of C# elements, and creation of necessary parameters. /// private async Task GenerateNewComponentAsync( - SelectionAnalysisResult selectionAnalysis, + ExtractToComponentCodeActionParams actionParams, RazorCodeDocument razorCodeDocument, - Uri componentUri, DocumentContext documentContext, Range selectionRange, - string whitespace, CancellationToken cancellationToken) { var contents = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); @@ -594,9 +160,9 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS var sbInstance = PooledStringBuilder.GetInstance(); var newFileContentBuilder = sbInstance.Builder; - if (selectionAnalysis.UsingDirectives is not null) + if (actionParams.UsingDirectives is not null) { - foreach (var dependency in selectionAnalysis.UsingDirectives) + foreach (var dependency in actionParams.UsingDirectives) { newFileContentBuilder.AppendLine($"@using {dependency}"); } @@ -608,24 +174,29 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS } var extractedContents = contents.GetSubTextString( - new TextSpan(selectionAnalysis.ExtractStart, - selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)) + new TextSpan(actionParams.ExtractStart, + actionParams.ExtractEnd - actionParams.ExtractStart)) .Trim(); // Remove leading whitespace from each line to maintain proper indentation in the new component - var extractedLines = extractedContents.Split('\n'); - for (var i = 1; i < extractedLines.Length; i++) + var extractedLines = ArrayBuilder.GetInstance(); + var dedentWhitespace = actionParams.DedentWhitespaceString; + if (!dedentWhitespace.IsNullOrEmpty()) { - var line = extractedLines[i]; - if (line.StartsWith(whitespace, StringComparison.Ordinal)) + extractedLines.AddRange(extractedContents.Split('\n')); + for (var i = 1; i < extractedLines.Count; i++) { - extractedLines[i] = line[whitespace.Length..]; + var line = extractedLines[i]; + if (line.StartsWith(dedentWhitespace, StringComparison.Ordinal)) + { + extractedLines[i] = line[dedentWhitespace.Length..]; + } } + + extractedContents = string.Join("\n", extractedLines); } - extractedContents = string.Join("\n", extractedLines); newFileContentBuilder.Append(extractedContents); - var result = new NewRazorComponentInfo { NewContents = newFileContentBuilder.ToString() @@ -633,15 +204,15 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS // Get CSharpStatements within component var syntaxTree = razorCodeDocument.GetSyntaxTree(); - var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd, out var atCodeBlock); + var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, actionParams.ExtractStart, actionParams.ExtractEnd, out var atCodeBlock); // Only make the Roslyn call if there is CSharp in the selected code // (code blocks, expressions, event handlers, binders) in the selected code, // or if the selection doesn't already include the @code block. // Assuming that if a user selects a @code along with markup, the @code block contains all necessary information for the component. - if (selectionAnalysis.HasAtCodeBlock || + if (actionParams.HasAtCodeBlock || atCodeBlock is null || - (!selectionAnalysis.HasEventHandlerOrExpression && + (!actionParams.HasEventHandlerOrExpression && cSharpCodeBlocks.Count == 0)) { sbInstance.Free(); @@ -654,8 +225,8 @@ atCodeBlock is null || throw new InvalidOperationException("Failed to retrieve document version."); } - var sourceMappings = razorCodeDocument.GetCSharpDocument().SourceMappings; var cSharpDocument = razorCodeDocument.GetCSharpDocument(); + var sourceMappings = cSharpDocument.SourceMappings; var sourceText = razorCodeDocument.Source.Text; var generatedSourceText = SourceText.From(cSharpDocument.GeneratedCode); @@ -678,6 +249,7 @@ atCodeBlock is null || .Select(range => range!) .ToArray(); + var componentUri = actionParams.Uri; var parameters = new GetSymbolicInfoParams() { Project = new TextDocumentIdentifier @@ -719,6 +291,9 @@ atCodeBlock is null || var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, promotedAttributes); // Capitalize attribute references in the new component to match C# naming conventions + // NOTE: This approach is not comprehensive. It capitalizes substrings that match attribute names. + // A more correct approach would be to use the generated c# document in Roslyn and modify field symbol identifiers there, + // then somehow build the razor content from the modified c# document, then pass that string back here. foreach (var attribute in componentInfo.Attributes) { var capitalizedAttributeName = CapitalizeString(attribute.Name); @@ -727,6 +302,7 @@ atCodeBlock is null || newFileContentBuilder.Append(newFileCodeBlock); + result.NewContents = newFileContentBuilder.ToString(); result.Methods = componentInfo.Methods; result.Attributes = componentInfo.Attributes; @@ -744,16 +320,50 @@ private static List GetCSharpCodeBlocks( var root = syntaxTree.Root; var span = new TextSpan(start, end - start); - // Get only CSharpSyntaxNodes without Razor Meta Code as ancestors. This avoids getting the @code block at the end of a razor file. - var cSharpCodeBlocks = root.DescendantNodes() - .Where(node => span.Contains(node.Span)) - .OfType() - .Where(cSharpNode => - !cSharpNode.Ancestors().OfType().Any()) - .ToList(); + // Get all CSharpStatementLiterals except those inside @code. + var cSharpCodeBlocks = new List(); + var insideAtCode = false; + CSharpStatementLiteralSyntax? tentativeAtCodeBlock = null; - atCodeBlock = root.DescendantNodes().OfType().LastOrDefault(); - atCodeBlock = atCodeBlock is not null && cSharpCodeBlocks.Contains(atCodeBlock) ? null : atCodeBlock; + void ProcessNode(SyntaxNode node) + { + if (node is RazorMetaCodeSyntax razorMetaCode) + { + foreach (var token in razorMetaCode.MetaCode) + { + if (token.Content == "code") + { + insideAtCode = true; + break; + } + } + } + else if (node is CSharpStatementLiteralSyntax cSharpNode && !cSharpNode.ContainsOnlyWhitespace()) + { + if (insideAtCode) + { + tentativeAtCodeBlock = cSharpNode; + } + else if (span.Contains(node.Span)) + { + cSharpCodeBlocks.Add(cSharpNode); + } + } + + foreach (var child in node.ChildNodes()) + { + ProcessNode(child); + } + + if (insideAtCode && node is CSharpCodeBlockSyntax) + { + insideAtCode = false; + } + } + + ProcessNode(root); + + atCodeBlock = tentativeAtCodeBlock; return cSharpCodeBlocks; } @@ -774,15 +384,15 @@ private static string GeneratePromotedMethods(MethodSymbolicInfo[] methods) // Start building delegate type builder.Append("\trequired public "); - builder.Append(method.ReturnType == "Void" ? "Action" : "Func"); + builder.Append(method.ReturnType == "void" ? "Action" : "Func"); // If delegate type is Action, only add generic parameters if needed. - if (method.ParameterTypes.Length > 0 || method.ReturnType != "Void") + if (method.ParameterTypes.Length > 0 || method.ReturnType != "void") { builder.Append('<'); builder.Append(string.Join(", ", method.ParameterTypes)); - if (method.ReturnType != "Void") + if (method.ReturnType != "void") { if (method.ParameterTypes.Length > 0) { @@ -818,7 +428,7 @@ private static string GeneratePromotedAttributes(AttributeSymbolicInfo[] attribu { var capitalizedFieldName = CapitalizeString(field.Name); - if ((field.IsValueType || field.Type == "String") && field.IsWrittenTo) + if ((field.IsValueType || field.Type == "string") && field.IsWrittenTo) { builder.AppendLine($"\t// Warning: Field '{capitalizedFieldName}' was passed by value and may not be referenced correctly. Please check its usage in the original document: '{sourceDocumentFileName}'."); } @@ -866,15 +476,34 @@ private static string GenerateNewFileCodeBlock(string promotedMethods, string pr { builder.AppendLine(promotedMethods); } + builder.Append("}"); return builder.ToString(); } + private static void CapitalizeFieldReferences(StringBuilder content, AttributeSymbolicInfo[] attributes) + { + var newRazorSourceDocument = RazorSourceDocument.Create(content.ToString(), "ExtractedComponent"); + var syntaxTree = RazorSyntaxTree.Parse(newRazorSourceDocument); + var root = syntaxTree.Root; + + // Traverse through the descendant nodes, and if we find an identifiernamesyntax whose name matches one of the attributes, capitalize it. + foreach (var node in root.DescendantNodes()) + { + if (node.Kind is not SyntaxKind.Identifier) + { + continue; + } + + var identifierNameString = node.GetContent(); + } + } + private static string GenerateComponentNameAndParameters(MethodSymbolicInfo[]? methods, AttributeSymbolicInfo[]? attributes, string componentName) { if (methods is null || attributes is null) { - return componentName; + return componentName + " "; } var builder = new StringBuilder(); @@ -882,13 +511,13 @@ private static string GenerateComponentNameAndParameters(MethodSymbolicInfo[]? m foreach (var method in methods) { - builder.Append($"{method.Name}={method.Name} "); + builder.Append($"{method.Name}=@{method.Name} "); } foreach (var attribute in attributes) { var capitalizedAttributeName = CapitalizeString(attribute.Name); - builder.Append($"{capitalizedAttributeName}={attribute.Name} "); + builder.Append($"{capitalizedAttributeName}=@{attribute.Name} "); } return builder.ToString();