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..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 @@ -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; 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..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,8 @@ 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 +17,23 @@ internal sealed class ExtractToComponentCodeActionParams public required Uri Uri { get; set; } [JsonPropertyName("extractStart")] - public int ExtractStart { get; set; } + public required int ExtractStart { get; set; } [JsonPropertyName("extractEnd")] - public int ExtractEnd { get; set; } + public required int ExtractEnd { get; set; } - [JsonPropertyName("namespace")] - public required string Namespace { get; set; } + [JsonPropertyName("hasEventHandlerOrExpression")] + public required bool HasEventHandlerOrExpression { get; set; } + + [JsonPropertyName("hasAtCodeBlock")] + public required bool HasAtCodeBlock { get; set; } [JsonPropertyName("usingDirectives")] - public required List usingDirectives { get; set; } + 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 d6d8967308a..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 @@ -6,184 +6,334 @@ 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; 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) { - if (context is null) + var telemetryDidSucceed = false; + using var _ = _telemetryReporter.BeginBlock("extractToComponentProvider", Severity.Normal, new Property("didSucceed", telemetryDidSucceed)); + + if (!IsValidContext(context)) { return SpecializedTasks.EmptyImmutableArray(); } - if (!context.SupportsFileCreation) + if (!TryGetNamespace(context.CodeDocument, out var @namespace)) { return SpecializedTasks.EmptyImmutableArray(); } - if (!FileKinds.IsComponent(context.CodeDocument.GetFileKind())) + var selectionAnalysisResult = TryAnalyzeSelection(context); + if (!selectionAnalysisResult.Success) { return SpecializedTasks.EmptyImmutableArray(); } - var syntaxTree = context.CodeDocument.GetSyntaxTree(); - if (syntaxTree?.Root is null) + var actionParams = new ExtractToComponentCodeActionParams { - return SpecializedTasks.EmptyImmutableArray(); - } + Uri = context.Request.TextDocument.Uri, + ExtractStart = selectionAnalysisResult.ExtractStart, + ExtractEnd = selectionAnalysisResult.ExtractEnd, + HasEventHandlerOrExpression = selectionAnalysisResult.HasEventHandlerOrExpression, + HasAtCodeBlock = selectionAnalysisResult.HasAtCodeBlock, + UsingDirectives = selectionAnalysisResult.UsingDirectives ?? Array.Empty(), + DedentWhitespaceString = selectionAnalysisResult.DedentWhitespaceString ?? string.Empty, + Namespace = @namespace, + }; - // Make sure the selection starts on an element tag - var (startElementNode, endElementNode) = GetStartAndEndElements(context, syntaxTree, _logger); - if (startElementNode is null) + var resolutionParams = new RazorCodeActionResolutionParams() { - return SpecializedTasks.EmptyImmutableArray(); + Action = LanguageServerConstants.CodeActions.ExtractToComponentAction, + Language = LanguageServerConstants.CodeActions.Languages.Razor, + Data = actionParams, + }; + + var codeAction = RazorCodeActionFactory.CreateExtractToComponent(resolutionParams); + + telemetryDidSucceed = true; + return Task.FromResult>([codeAction]); + } + + private static bool IsValidContext(RazorCodeActionContext context) + { + return context is not null && + context.SupportsFileCreation && + FileKinds.IsComponent(context.CodeDocument.GetFileKind()) && + !context.CodeDocument.IsUnsupported() && + context.CodeDocument.GetSyntaxTree() is not null; + } + + internal sealed record SelectionAnalysisResult + { + 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 new SelectionAnalysisResult { Success = false }; } - if (endElementNode is null) + (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)) { - endElementNode = startElementNode; + return new SelectionAnalysisResult { Success = false }; } - if (!TryGetNamespace(context.CodeDocument, out var @namespace)) + // 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 SpecializedTasks.EmptyImmutableArray(); + return new SelectionAnalysisResult { Success = false }; } - var actionParams = CreateInitialActionParams(context, startElementNode, @namespace); + // 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); - ProcessSelection(startElementNode, endElementNode, actionParams); + // 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. - var utilityScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; + // 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); - // 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()); + if (!startWhitespace.IsNullOrEmpty()) + { + dedentWhitespace = startWhitespace; + } + } - // Get only the namespace after the "using" keyword. - var usingNamespaceStrings = usingStrings.Select(usingString => usingString.Substring("using ".Length)); + if (isValidEndElementNode && + endOwner?.TryGetPreviousSibling(out whitespaceNode) == true && + whitespaceNode.ContainsOnlyWhitespace()) + { + var endDedentWhitespace = whitespaceNode.ToFullString(); + endDedentWhitespace = endDedentWhitespace.Replace("\r", string.Empty).Replace("\n", string.Empty); - AddUsingDirectivesInRange(utilityScanRoot, - usingNamespaceStrings, - actionParams.ExtractStart, - actionParams.ExtractEnd, - actionParams); + if (!endDedentWhitespace.IsNullOrEmpty() && + (dedentWhitespace.IsNullOrEmpty() || + endDedentWhitespace.Length < dedentWhitespace.Length)) + { + dedentWhitespace = endDedentWhitespace; + } + } - var resolutionParams = new RazorCodeActionResolutionParams() + return new SelectionAnalysisResult { - Action = LanguageServerConstants.CodeActions.ExtractToNewComponentAction, - Language = LanguageServerConstants.CodeActions.Languages.Razor, - Data = actionParams, + Success = true, + ExtractStart = extractStart, + ExtractEnd = extractEnd, + HasAtCodeBlock = hasAtCodeBlock, + HasEventHandlerOrExpression = hasOtherIdentifiers, + UsingDirectives = usingDirectivesInRange.ToArray(), + DedentWhitespaceString = dedentWhitespace }; - - var codeAction = RazorCodeActionFactory.CreateExtractToComponent(resolutionParams); - return Task.FromResult>([codeAction]); } - private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAndEndElements(RazorCodeActionContext context, RazorSyntaxTree syntaxTree, ILogger logger) + private static (SyntaxNode owner, MarkupSyntaxNode? startElementNode, bool hasAtCodeBlock) AnalyzeSelectionStart(SyntaxNode startOwner) { - var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); - if (owner is null) + var elementNode = startOwner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); + var hasAtCodeBlock = false; + + if (elementNode is null) { - logger.LogWarning($"Owner should never be null."); - return (null, null); + var codeBlock = startOwner.FirstAncestorOrSelf(); + if (codeBlock is not null) + { + hasAtCodeBlock = true; + startOwner = codeBlock; + } } - var startElementNode = owner.FirstAncestorOrSelf(); - if (startElementNode is null || IsInsideProperHtmlContent(context, startElementNode)) + 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)) { - return (null, null); + elementNode = previousMarkupSibling.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); } - var endElementNode = GetEndElementNode(context, syntaxTree); + // 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 (startElementNode, endElementNode); + return (endOwner, elementNode, hasAtCodeBlock); } - private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, MarkupElementSyntax startElementNode) + 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 the provider executes before the user/completion inserts an end tag, the below return fails - if (startElementNode.EndTag.IsMissing) + if (endOwner.TryGetPreviousSibling(out var previousSibling)) { - return true; + return previousSibling.FirstAncestorOrSelf() ?? endOwner; } - - return context.Location.AbsoluteIndex > startElementNode.StartTag.Span.End && - context.Location.AbsoluteIndex < startElementNode.EndTag.SpanStart; + return endOwner; } - private static MarkupElementSyntax? GetEndElementNode(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) + private static bool IsOnMarkupTag(int absoluteIndex, SyntaxNode owner) { - var selectionStart = context.Request.Range.Start; - var selectionEnd = context.Request.Range.End; - if (selectionStart == selectionEnd) - { - return null; - } + var (startTag, endTag) = GetStartAndEndTag(owner); - var endAbsoluteIndex = context.SourceText.GetRequiredAbsoluteIndex(selectionEnd); - var endOwner = syntaxTree.Root.FindInnermostNode(endAbsoluteIndex, true); - if (endOwner is null) + if (startTag is null) { - return null; + return false; } - // 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; - } + endTag ??= startTag; // Self-closing tag - return endOwner.FirstAncestorOrSelf(); + 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 ExtractToComponentCodeActionParams CreateInitialActionParams(RazorCodeActionContext context, MarkupElementSyntax startElementNode, string @namespace) + private static (MarkupSyntaxNode? startTag, MarkupSyntaxNode? endTag) GetStartAndEndTag(SyntaxNode owner) { - return new ExtractToComponentCodeActionParams + var potentialElement = owner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); + + return potentialElement switch { - Uri = context.Request.TextDocument.Uri, - ExtractStart = startElementNode.Span.Start, - ExtractEnd = startElementNode.Span.End, - Namespace = @namespace, - usingDirectives = [] + 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) + // 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); + ///

- /// Processes a multi-point selection to determine the correct range for extraction. + /// Processes a selection, modifying and in place to correct selection bounds. /// - /// 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) + /// 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 there's no end element, we can't process a multi-point selection - if (endElementNode is null) + if (ReferenceEquals(startNode, endNode)) { - return; + return true; } - var startNodeContainsEndNode = endElementNode.Ancestors().Any(node => node == startElementNode); + // 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 the start element is an ancestor, keep the original end; otherwise, use the end of the end element - if (startNodeContainsEndNode) + if (endNode.Span.Contains(startNode.Span)) { - actionParams.ExtractEnd = startElementNode.Span.End; - return; + startNode = endNode; + return true; } - // If the start element is not an ancestor of the end element, we need to find a common parent + // 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: //
@@ -193,30 +343,33 @@ private static void ProcessSelection(MarkupElementSyntax startElementNode, Marku // //

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) + var (selectStart, selectEnd) = FindContainingSiblingPair(startNode, endNode); + if (selectStart is not null && selectEnd is not null) { - actionParams.ExtractStart = extractStart.Span.Start; - actionParams.ExtractEnd = extractEnd.Span.End; + startNode = selectStart; + endNode = selectEnd; + + return true; } + // Note: If we don't find a valid pair, we keep the original extraction range + return true; + } - 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); - + /// + /// 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 @@ -233,22 +386,36 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy var startSpan = startNode.Span; var endSpan = endNode.Span; - foreach (var child in nearestCommonAncestor.ChildNodes().Where(static node => node.Kind == SyntaxKind.MarkupElement)) + 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)) + if (startContainingNode is null && + childSpan.Contains(startSpan) && + ( + (startIsCodeBlock && child is CSharpCodeBlockSyntax) || + (!startIsCodeBlock && (child is MarkupElementSyntax or MarkupTagHelperElementSyntax)) + )) { startContainingNode = child; - if (endContainingNode is not null) - break; // Exit if we've found both } - if (childSpan.Contains(endSpan)) + if (endContainingNode is null && + childSpan.Contains(endSpan) && + ( + (endIsCodeBlock && child is CSharpCodeBlockSyntax) || + (!endIsCodeBlock && (child is MarkupElementSyntax or MarkupTagHelperElementSyntax)) + )) { endContainingNode = child; - if (startContainingNode is not null) - break; // Exit if we've found both + } + + if (startContainingNode is not null && endContainingNode is not null) + { + break; } } @@ -257,39 +424,137 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy private static SyntaxNode? FindNearestCommonAncestor(SyntaxNode node1, SyntaxNode node2) { - var current = node1; - - while (current is MarkupElementSyntax or - MarkupTagHelperAttributeSyntax or - MarkupBlockSyntax && - current is not null) + for (var current = node1; current is not null; current = current.Parent) { - if (current.Span.Contains(node2.Span)) + if (IsValidAncestorNode(current) && 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) + 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 components = new HashSet(); 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 root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) + 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, components, usingsInSourceRazor, actionParams); + 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 components, IEnumerable usingsInSourceRazor, ExtractToComponentCodeActionParams actionParams) + private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet usings, string[] usingsInSourceRazor) { foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) { @@ -318,9 +583,9 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS { var partialNamespace = string.Join(".", parts.Skip(i)); - if (components.Add(partialNamespace) && usingsInSourceRazor.Contains(partialNamespace)) + if (usingsInSourceRazor.Contains(partialNamespace)) { - actionParams.usingDirectives.Add($"@using {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 4cff9dc9a9d..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 @@ -3,49 +3,56 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; 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; +using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; +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.Text; 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.Razor.Protocol; +using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; -using Newtonsoft.Json.Linq; +using Microsoft.VisualStudio.Utilities; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; +using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; -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 + LanguageServerFeatureOptions languageServerFeatureOptions, + IClientConnection clientConnection, + 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.ExtractToNewComponentAction; + public string Action => LanguageServerConstants.CodeActions.ExtractToComponentAction; public async Task ResolveAsync(JsonElement data, CancellationToken cancellationToken) { - if (data.ValueKind == JsonValueKind.Undefined) - { - return null; - } + var telemetryDidSucceed = false; + using var _ = _telemetryReporter.BeginBlock("extractToComponentResolver", Severity.Normal, new Property("didSucceed", telemetryDidSucceed)); - var actionParams = JsonSerializer.Deserialize(data.GetRawText()); + var actionParams = data.Deserialize(); if (actionParams is null) { return null; @@ -56,16 +63,11 @@ internal sealed class ExtractToComponentCodeActionResolver return null; } - var componentDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); - if (componentDocument.IsUnsupported()) - { - return null; - } + var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + var startLinePosition = codeDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractStart); + var endLinePosition = codeDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractEnd); - if (!FileKinds.IsComponent(componentDocument.GetFileKind())) - { - return null; - } + var removeRange = VsLspFactory.CreateRange(startLinePosition, endLinePosition); var path = FilePathNormalizer.Normalize(actionParams.Uri.GetAbsoluteOrUNCPath()); var directoryName = Path.GetDirectoryName(path).AssumeNotNull(); @@ -84,30 +86,21 @@ 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 newComponentResult = await GenerateNewComponentAsync( + actionParams, + codeDocument, + documentContext, + removeRange, + cancellationToken).ConfigureAwait(false); - newComponentContent += string.Join(Environment.NewLine, actionParams.usingDirectives); - if (actionParams.usingDirectives.Count > 0) + if (newComponentResult 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 newComponentContent = newComponentResult.NewContents; + var componentNameAndParams = GenerateComponentNameAndParameters(newComponentResult.Methods, newComponentResult.Attributes, componentName); var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; @@ -118,32 +111,422 @@ internal sealed class ExtractToComponentCodeActionResolver new TextDocumentEdit { TextDocument = componentDocumentIdentifier, - Edits = new[] - { + Edits = + [ new TextEdit { - NewText = $"<{componentName} />", + NewText = $"<{componentNameAndParams}/>", 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) }, } - }, + ], } }; + telemetryDidSucceed = true; return new WorkspaceEdit { DocumentChanges = documentChanges, }; } + + /// + /// 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( + ExtractToComponentCodeActionParams actionParams, + RazorCodeDocument razorCodeDocument, + DocumentContext documentContext, + Range selectionRange, + CancellationToken cancellationToken) + { + var contents = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); + if (contents is null) + { + return null; + } + + var sbInstance = PooledStringBuilder.GetInstance(); + var newFileContentBuilder = sbInstance.Builder; + if (actionParams.UsingDirectives is not null) + { + foreach (var dependency in actionParams.UsingDirectives) + { + newFileContentBuilder.AppendLine($"@using {dependency}"); + } + + if (newFileContentBuilder.Length > 0) + { + newFileContentBuilder.AppendLine(); + } + } + + var extractedContents = contents.GetSubTextString( + 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 = ArrayBuilder.GetInstance(); + var dedentWhitespace = actionParams.DedentWhitespaceString; + if (!dedentWhitespace.IsNullOrEmpty()) + { + extractedLines.AddRange(extractedContents.Split('\n')); + for (var i = 1; i < extractedLines.Count; i++) + { + var line = extractedLines[i]; + if (line.StartsWith(dedentWhitespace, StringComparison.Ordinal)) + { + extractedLines[i] = line[dedentWhitespace.Length..]; + } + } + + extractedContents = string.Join("\n", extractedLines); + } + + newFileContentBuilder.Append(extractedContents); + var result = new NewRazorComponentInfo + { + NewContents = newFileContentBuilder.ToString() + }; + + // Get CSharpStatements within component + var syntaxTree = razorCodeDocument.GetSyntaxTree(); + 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 (actionParams.HasAtCodeBlock || + atCodeBlock is null || + (!actionParams.HasEventHandlerOrExpression && + cSharpCodeBlocks.Count == 0)) + { + sbInstance.Free(); + return result; + } + + if (!_documentVersionCache.TryGetDocumentVersion(documentContext.Snapshot, out var version)) + { + sbInstance.Free(); + throw new InvalidOperationException("Failed to retrieve document version."); + } + + var cSharpDocument = razorCodeDocument.GetCSharpDocument(); + var sourceMappings = cSharpDocument.SourceMappings; + 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 => + ( + OriginalRange: RazorDiagnosticConverter.ConvertSpanToRange(m.OriginalSpan, sourceText), + 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(); + + var intersectingGeneratedRanges = intersectingGeneratedSpans + .Select(m =>RazorDiagnosticConverter.ConvertSpanToRange(m, generatedSourceText)) + .Where(range => range != null) + .Select(range => range!) + .ToArray(); + + var componentUri = actionParams.Uri; + var parameters = new GetSymbolicInfoParams() + { + Project = new TextDocumentIdentifier + { + Uri = new Uri(documentContext.Project.FilePath, UriKind.Absolute) + }, + Document = new TextDocumentIdentifier + { + Uri = componentUri + }, + HostDocumentVersion = version.Value, + GeneratedDocumentRanges = intersectingGeneratedRanges + }; + + MemberSymbolicInfo? componentInfo; + + // Send a request to the language server to get symbolic information about the extracted code + try + { + componentInfo = await _clientConnection.SendRequestAsync( + CustomMessageNames.RazorGetSymbolicInfoEndpointName, + parameters, + cancellationToken: default).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to send request to Roslyn endpoint", ex); + } + + if (componentInfo is null) + { + sbInstance.Free(); + throw new InvalidOperationException("Roslyn endpoint 'GetSymbolicInfo' returned null"); + } + + // 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); + + // 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); + newFileContentBuilder.Replace(attribute.Name, capitalizedAttributeName); + } + + newFileContentBuilder.Append(newFileCodeBlock); + + + result.NewContents = newFileContentBuilder.ToString(); + result.Methods = componentInfo.Methods; + result.Attributes = componentInfo.Attributes; + + sbInstance.Free(); + return result; + } + + 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 all CSharpStatementLiterals except those inside @code. + var cSharpCodeBlocks = new List(); + var insideAtCode = false; + CSharpStatementLiteralSyntax? tentativeAtCodeBlock = null; + + 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; + } + + // 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(MethodSymbolicInfo[] methods) + { + var builder = new StringBuilder(); + var parameterCount = 0; + var totalMethods = methods.Length; + + foreach (var method in methods) + { + builder.AppendLine($"\t/// Delegate for the '{method.Name}' method."); + builder.AppendLine("\t[Parameter]"); + + // Start building delegate type + 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") + { + builder.Append('<'); + builder.Append(string.Join(", ", method.ParameterTypes)); + + if (method.ReturnType != "void") + { + 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); + } + + builder.Append('>'); + } + + builder.Append($" {method.Name} {{ get; set; }}"); + if (parameterCount++ < totalMethods - 1) + { + // Space between methods except for the last method. + builder.AppendLine(); + builder.AppendLine(); + } + } + + return builder.ToString(); + } + + private static string GeneratePromotedAttributes(AttributeSymbolicInfo[] attributes, string? sourceDocumentFileName) + { + var builder = new StringBuilder(); + var attributeCount = 0; + var totalAttributes = attributes.Length; + + foreach (var field in attributes) + { + var capitalizedFieldName = CapitalizeString(field.Name); + + 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}'."); + } + + builder.AppendLine("\t[Parameter]"); + + // Members cannot be less visible than their enclosing type, so we don't need to check for private fields. + builder.Append($"\trequired public {field.Type} {capitalizedFieldName} {{ get; set; }}"); + + if (attributeCount++ < totalAttributes - 1) + { + builder.AppendLine(); + builder.AppendLine(); + } + } + + return builder.ToString(); + } + + // 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 {"); + 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(); + } + + 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 + " "; + } + + var builder = new StringBuilder(); + builder.Append(componentName + " "); + + foreach (var method in methods) + { + builder.Append($"{method.Name}=@{method.Name} "); + } + + foreach (var attribute in attributes) + { + var capitalizedAttributeName = CapitalizeString(attribute.Name); + builder.Append($"{capitalizedAttributeName}=@{attribute.Name} "); + } + + return builder.ToString(); + } + + internal sealed record NewRazorComponentInfo + { + public required string NewContents { 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 new file mode 100644 index 00000000000..6a45afb5ca8 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs @@ -0,0 +1,52 @@ +// 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.Runtime.Serialization; +using System.Text.Json.Serialization; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; + +[DataContract] +internal record GetSymbolicInfoParams +{ + [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 = "generatedDocumentRanges")] + [JsonPropertyName("generatedDocumentRanges")] + public required Range[] GeneratedDocumentRanges { get; set; } +} + +internal sealed record MemberSymbolicInfo +{ + public required MethodSymbolicInfo[] Methods { get; set; } + public required AttributeSymbolicInfo[] Attributes { get; set; } +} + +internal sealed record MethodSymbolicInfo +{ + public required string Name { get; set; } + + public required string ReturnType { get; set; } + + public required string[] ParameterTypes { get; set; } +} + +internal sealed record AttributeSymbolicInfo +{ + public required string Name { get; set; } + public required string Type { get; set; } + public required bool IsValueType { get; set; } + public required bool IsWrittenTo { 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..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,6 +13,7 @@ namespace Microsoft.CodeAnalysis.Razor.Protocol; internal static class CustomMessageNames { // VS Windows only + 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.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs index 57d2748e1bd..31030043325 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServer.ContainedLanguage/DefaultLSPRequestInvoker.cs @@ -55,8 +55,8 @@ public async override Task> ReinvokeRequestOnServerAsync< } var response = await _languageServiceBroker.RequestAsync( - new GeneralRequest { LanguageServerName = languageServerName, Method = method, Request = parameters }, - cancellationToken); + 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_GetSymbolicInfo.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs new file mode 100644 index 00000000000..b1db913a84b --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs @@ -0,0 +1,43 @@ +// 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 StreamJsonRpc; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Endpoints; + +internal partial class RazorCustomMessageTarget +{ + [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) + { + return null; + } + + request.Document.Uri = virtualDocumentSnapshot.Uri; + ReinvokeResponse response; + + try + { + response = await _requestInvoker.ReinvokeRequestOnServerAsync( + RazorLSPConstants.RoslynGetSymbolicInfoEndpointName, + 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; + } +} 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..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,5 +27,7 @@ internal static class RazorLSPConstants public const string RoslynFormatNewFileEndpointName = "roslyn/formatNewFile"; + 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.ExtractToComponent.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs new file mode 100644 index 00000000000..4a92da116b4 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs @@ -0,0 +1,777 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +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; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Moq; +using Roslyn.Test.Utilities; +using Xunit; + +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); + + 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, + mockTelemetry.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

+
+ """; + + 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_SiblingElement_ReturnsResult() + { + var input = """ + <[|div id="a"> +

Div a title

+ +

Div a par

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

Div a title

+ +

Div a 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_StartNodeContainsEndNode_ReturnsResult() + { + var input = """ + <[|div id="parent"> +
+
+
+

Deeply nested par +

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

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_EndNodeContainsStartNode_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|p>Deeply nested par

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

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_IndentedNode_ReturnsResult() + { + var input = """ +
+
+ <[||]div> +
+

Deeply nested par

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

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_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

+
+ """; + + 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_IndentedStartNodeContainsEndNode_ReturnsResult() + { + var input = """ +
+
+ <[|div> +
+

Deeply nested par +

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

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_IndentedEndNodeContainsStartNode_ReturnsResult() + { + var input = """ +
+
+
+
+ <[|p>Deeply nested par

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

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_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, 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, + 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.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 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 992ff410c0e..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 @@ -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; @@ -18,25 +17,31 @@ 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 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"; @@ -61,15 +66,6 @@ private GenerateMethodCodeActionResolver[] CreateRazorCodeActionResolvers( razorFormattingService) ]; - private ExtractToComponentCodeActionResolver[] CreateExtractComponentCodeActionResolver(string filePath, RazorCodeDocument codeDocument) - { - return [ - new ExtractToComponentCodeActionResolver( - new GenerateMethodResolverDocumentContextFactory(filePath, codeDocument), - TestLanguageServerFeatureOptions.Instance) - ]; - } - #region CSharp CodeAction Tests [Fact] @@ -1016,104 +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); - } - #endregion private async Task ValidateCodeBehindFileAsync( @@ -1257,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) ?? []); - - 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; 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 d884dc9c0b9..00000000000 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ /dev/null @@ -1,311 +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_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); - - 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); - } - - 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; - } -}