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);