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 b99db47af11..cead4c539b1 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 @@ -46,7 +46,7 @@ public Task> ProvideAsync(RazorCodeAct } var syntaxTree = context.CodeDocument.GetSyntaxTree(); - if (!IsSelectionValid(context, syntaxTree)) + if (!IsValidSelection(context, syntaxTree)) { return SpecializedTasks.EmptyImmutableArray(); } @@ -79,7 +79,7 @@ private static bool IsValidContext(RazorCodeActionContext context) context.CodeDocument.GetSyntaxTree()?.Root is not null; } - private bool IsSelectionValid(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) + private bool IsValidSelection(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) { var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); if (owner is null) @@ -89,33 +89,33 @@ private bool IsSelectionValid(RazorCodeActionContext context, RazorSyntaxTree sy } var startElementNode = owner.FirstAncestorOrSelf(node => node is MarkupElementSyntax or MarkupTagHelperElementSyntax); - return startElementNode is not null && !HasDiagnosticErrors(startElementNode) && !IsInsideProperHtmlContent(context, owner); + return startElementNode is not null && HasNoDiagnosticErrors(startElementNode) && IsInsideMarkupTag(context, owner); } - private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, SyntaxNode owner) + private static bool IsInsideMarkupTag(RazorCodeActionContext context, SyntaxNode owner) { // The selection could start either in a MarkupElement or MarkupTagHelperElement. - // Both of these have the necessary properties to do this check, but not the base MarkupSyntaxNode. - // The workaround for this is to try to cast to the specific types and then do the check. + // Both of these have the necessary properties to do this check, but the base class MarkupSyntaxNode does not. + // The workaround for this is to try to find the specific types as ancestors and then do the check. var tryMakeMarkupElement = owner.FirstAncestorOrSelf(); var tryMakeMarkupTagHelperElement = owner.FirstAncestorOrSelf(); - var isLocationInProperMarkupElement = tryMakeMarkupElement is not null && - context.Location.AbsoluteIndex > tryMakeMarkupElement.StartTag.Span.End && - context.Location.AbsoluteIndex < tryMakeMarkupElement.EndTag.SpanStart; + var isLocationInElementTag = tryMakeMarkupElement is not null && + (tryMakeMarkupElement.StartTag.Span.Contains(context.Location.AbsoluteIndex) || + tryMakeMarkupElement.EndTag.Span.Contains(context.Location.AbsoluteIndex)); - var isLocationInProperMarkupTagHelper = tryMakeMarkupTagHelperElement is not null && - context.Location.AbsoluteIndex > tryMakeMarkupTagHelperElement.StartTag.Span.End && - context.Location.AbsoluteIndex < tryMakeMarkupTagHelperElement.EndTag.SpanStart; + var isLocationInTagHelperTag = tryMakeMarkupTagHelperElement is not null && + (tryMakeMarkupTagHelperElement.StartTag.Span.Contains(context.Location.AbsoluteIndex) || + tryMakeMarkupTagHelperElement.EndTag.Span.Contains(context.Location.AbsoluteIndex)); - return isLocationInProperMarkupElement || isLocationInProperMarkupTagHelper; + return isLocationInElementTag || isLocationInTagHelperTag; } - private static bool HasDiagnosticErrors(MarkupSyntaxNode markupElement) + private static bool HasNoDiagnosticErrors(MarkupSyntaxNode markupElement) { var diagnostics = markupElement.GetDiagnostics(); - return diagnostics.Any(d => d.Severity == RazorDiagnosticSeverity.Error); + return !diagnostics.Any(d => d.Severity == RazorDiagnosticSeverity.Error); } private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen(returnValue: true)] out string? @namespace) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 03cdb194a0e..030fed206f7 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 @@ -40,6 +40,7 @@ using Microsoft.CodeAnalysis.Razor.DocumentMapping; using System.Reflection.Metadata.Ecma335; using Microsoft.VisualStudio.Utilities; +using Microsoft.AspNetCore.Razor.LanguageServer.Diagnostics; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -123,7 +124,14 @@ internal sealed class ExtractToComponentCodeActionResolver( }.Uri; var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentResult = await GenerateNewComponentAsync(selectionAnalysis, codeDocument, actionParams.Uri, documentContext, removeRange, whitespace, cancellationToken).ConfigureAwait(false); + var newComponentResult = await GenerateNewComponentAsync( + selectionAnalysis, + codeDocument, + actionParams.Uri, + documentContext, + removeRange, + whitespace, + cancellationToken).ConfigureAwait(false); if (newComponentResult is null) { @@ -131,7 +139,7 @@ internal sealed class ExtractToComponentCodeActionResolver( } var newComponentContent = newComponentResult.NewContents; - var componentNameAndParams = GenerateComponentNameAndParameters(newComponentResult.Methods, componentName); + var componentNameAndParams = GenerateComponentNameAndParameters(newComponentResult.Methods, newComponentResult.Attributes, componentName); var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; @@ -183,8 +191,9 @@ internal sealed record SelectionAnalysisResult public required bool Success; public int ExtractStart; public int ExtractEnd; + public bool HasAtCodeBlock; + public bool HasEventHandlerOrExpression; public HashSet? UsingDirectives; - public HashSet? TentativeVariableDependencies; } private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams) @@ -196,13 +205,14 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod } endElementNode ??= startElementNode; - + var success = TryProcessSelection(startElementNode, endElementNode, codeDocument, actionParams, out var extractStart, - out var extractEnd); + out var extractEnd, + out var hasAtCodeBlock); if (!success) { @@ -210,16 +220,17 @@ private static SelectionAnalysisResult TryAnalyzeSelection(RazorCodeDocument cod } var dependencyScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - var componentDependencies = AddUsingDirectivesInRange(dependencyScanRoot, extractStart, extractEnd); - var variableDependencies = AddVariableDependenciesInRange(dependencyScanRoot, extractStart, extractEnd); + var usingDirectives = GetUsingDirectivesInRange(dependencyScanRoot, extractStart, extractEnd); + var hasOtherIdentifiers = CheckHasOtherIdentifiers(dependencyScanRoot, extractStart, extractEnd); return new SelectionAnalysisResult { Success = success, ExtractStart = extractStart, ExtractEnd = extractEnd, - UsingDirectives = componentDependencies, - TentativeVariableDependencies = variableDependencies, + HasAtCodeBlock = hasAtCodeBlock, + HasEventHandlerOrExpression = hasOtherIdentifiers, + UsingDirectives = usingDirectives, }; } @@ -302,11 +313,20 @@ private static (MarkupSyntaxNode? Start, MarkupSyntaxNode? End) GetStartAndEndEl /// The parameters for the extraction action /// The start of the extraction range. /// The end of the extraction range + /// Whether the selection has a @code block /// true if the selection was successfully processed; otherwise, false. - private static bool TryProcessSelection(MarkupSyntaxNode startElementNode, MarkupSyntaxNode endElementNode, RazorCodeDocument codeDocument, ExtractToComponentCodeActionParams actionParams, out int extractStart, out int extractEnd) + private static bool TryProcessSelection( + MarkupSyntaxNode startElementNode, + MarkupSyntaxNode endElementNode, + RazorCodeDocument codeDocument, + ExtractToComponentCodeActionParams actionParams, + out int extractStart, + out int extractEnd, + out bool hasCodeBlock) { extractStart = startElementNode.Span.Start; extractEnd = endElementNode.Span.End; + hasCodeBlock = false; // Check if it's a multi-point selection if (actionParams.SelectStart == actionParams.SelectEnd) @@ -371,6 +391,7 @@ private static bool TryProcessSelection(MarkupSyntaxNode startElementNode, Marku if (endCodeBlock is not null) { + hasCodeBlock = true; var (withCodeBlockStart, withCodeBlockEnd) = FindContainingSiblingPair(startElementNode, endCodeBlock); extractStart = withCodeBlockStart?.Span.Start ?? extractStart; extractEnd = withCodeBlockEnd?.Span.End ?? extractEnd; @@ -379,6 +400,13 @@ private static bool TryProcessSelection(MarkupSyntaxNode startElementNode, Marku return true; } + /// + /// Finds the smallest set of sibling nodes that contain both the start and end nodes. + /// This is useful for determining the scope of a selection that spans across different levels of the syntax tree. + /// + /// The node where the selection starts. + /// The node where the selection ends. + /// A tuple containing the start and end nodes of the containing sibling pair. private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) { // Find the lowest common ancestor of both nodes @@ -410,11 +438,13 @@ private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(Sy startContainingNode = child; } + // Check if this child contains the end node if (childSpan.Contains(endSpan)) { endContainingNode = child; } + // If we've found both containing nodes, we can stop searching if (startContainingNode is not null && endContainingNode is not null) { break; @@ -444,14 +474,19 @@ private static bool IsValidNode(SyntaxNode node, bool isCodeBlock) return node is MarkupElementSyntax or MarkupTagHelperElementSyntax || (isCodeBlock && node is CSharpCodeBlockSyntax); } - private static HashSet AddUsingDirectivesInRange(SyntaxNode root, int extractStart, int extractEnd) + private static HashSet GetUsingDirectivesInRange(SyntaxNode root, int extractStart, int extractEnd) { var usings = new HashSet(); var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); // Only analyze nodes within the extract span - foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) + foreach (var node in root.DescendantNodes()) { + if (!extractSpan.Contains(node.Span)) + { + continue; + } + if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) { AddUsingFromTagHelperInfo(tagHelperInfo, usings); @@ -461,26 +496,57 @@ private static HashSet AddUsingDirectivesInRange(SyntaxNode root, int ex return usings; } - private static HashSet AddVariableDependenciesInRange(SyntaxNode root, int extractStart, int extractEnd) + private static bool CheckHasOtherIdentifiers(SyntaxNode root, int extractStart, int extractEnd) { - var dependencies = new HashSet(); var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); - var candidates = root.DescendantNodes().Where(node => extractSpan.Contains(node.Span)); - - foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) + foreach (var node in root.DescendantNodes()) { - if (node is MarkupTagHelperAttributeValueSyntax tagAttribute) + if (!extractSpan.Contains(node.Span)) { - dependencies.Add(tagAttribute.ToFullString()); + continue; } - else if (node is CSharpImplicitExpressionBodySyntax implicitExpression) + + // An assumption I'm making that might be wrong: + // CSharpImplicitExpressionBodySyntax, CSharpExplicitExpressionBodySyntax, and MarkupTagHelperDirectiveAttributeSyntax + // nodes contain only one child of type CSharpExpressionLiteralSyntax + + // For MarkupTagHelperDirectiveAttributeSyntax, the syntax tree seems to show only one child of the contained CSharpExpressionLiteral as Text, + // so it might not be worth it to check for identifiers, but only if the above is true in all cases. + + if (node is CSharpImplicitExpressionBodySyntax or CSharpExplicitExpressionBodySyntax) { - dependencies.Add(implicitExpression.ToFullString()); + + var expressionLiteral = node.DescendantNodes().OfType().SingleOrDefault(); + if (expressionLiteral is null) + { + continue; + } + + foreach (var token in expressionLiteral.LiteralTokens) + { + if (token.Kind is Language.SyntaxKind.Identifier) + { + return true; + } + } + } + else if (node is MarkupTagHelperDirectiveAttributeSyntax directiveAttribute) + { + var attributeDelegate = directiveAttribute.DescendantNodes().OfType().SingleOrDefault(); + if (attributeDelegate is null) + { + continue; + } + + if (attributeDelegate.LiteralTokens.FirstOrDefault() is Language.Syntax.SyntaxToken { Kind: Language.SyntaxKind.Text }) + { + return true; + } } } - return dependencies; + return false; } private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet dependencies) @@ -497,12 +563,16 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS } } + /// + /// Generates a new Razor component based on the selected content from existing markup. + /// This method handles the extraction of code, processing of C# elements, and creation of necessary parameters. + /// private async Task GenerateNewComponentAsync( SelectionAnalysisResult selectionAnalysis, RazorCodeDocument razorCodeDocument, Uri componentUri, DocumentContext documentContext, - Range relevantRange, + Range selectionRange, string whitespace, CancellationToken cancellationToken) { @@ -512,8 +582,8 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS return null; } - var inst = PooledStringBuilder.GetInstance(); - var newFileContentBuilder = inst.Builder; + var sbInstance = PooledStringBuilder.GetInstance(); + var newFileContentBuilder = sbInstance.Builder; if (selectionAnalysis.UsingDirectives is not null) { foreach (var dependency in selectionAnalysis.UsingDirectives) @@ -532,7 +602,7 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS selectionAnalysis.ExtractEnd - selectionAnalysis.ExtractStart)) .Trim(); - // Go through each line of the extractedContents and remove the whitespace from the beginning of each line. + // Remove leading whitespace from each line to maintain proper indentation in the new component var extractedLines = extractedContents.Split('\n'); for (var i = 1; i < extractedLines.Length; i++) { @@ -546,51 +616,57 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS extractedContents = string.Join("\n", extractedLines); newFileContentBuilder.Append(extractedContents); - // Get CSharpStatements within component - var syntaxTree = razorCodeDocument.GetSyntaxTree(); - var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd); - var result = new NewRazorComponentInfo { - NewContents = newFileContentBuilder.ToString(), - Methods = [] + NewContents = newFileContentBuilder.ToString() }; - // Only make the Roslyn call if there is valid CSharp in the selected code. - if (cSharpCodeBlocks.Count == 0) - { - inst.Free(); + // Get CSharpStatements within component + var syntaxTree = razorCodeDocument.GetSyntaxTree(); + var cSharpCodeBlocks = GetCSharpCodeBlocks(syntaxTree, selectionAnalysis.ExtractStart, selectionAnalysis.ExtractEnd, out var atCodeBlock); + + // Only make the Roslyn call if there is CSharp in the selected code + // (code blocks, expressions, event handlers, binders) in the selected code, + // or if the selection doesn't already include the @code block. + // Assuming that if a user selects a @code along with markup, the @code block contains all necessary information for the component. + if (selectionAnalysis.HasAtCodeBlock || + atCodeBlock is null || + (!selectionAnalysis.HasEventHandlerOrExpression && + cSharpCodeBlocks.Count == 0)) + { + sbInstance.Free(); return result; } if (!_documentVersionCache.TryGetDocumentVersion(documentContext.Snapshot, out var version)) { - inst.Free(); - return result; + sbInstance.Free(); + throw new InvalidOperationException("Failed to retrieve document version."); } - + var sourceMappings = razorCodeDocument.GetCSharpDocument().SourceMappings; + var cSharpDocument = razorCodeDocument.GetCSharpDocument(); + var sourceText = razorCodeDocument.Source.Text; + var generatedSourceText = SourceText.From(cSharpDocument.GeneratedCode); + + // Create mappings between the original Razor source and the generated C# code var sourceMappingRanges = sourceMappings.Select(m => ( - new Range - { - Start = new Position(m.OriginalSpan.LineIndex, m.OriginalSpan.CharacterIndex), - End = new Position(m.OriginalSpan.LineIndex, m.OriginalSpan.EndCharacterIndex) - }, + OriginalRange: RazorDiagnosticConverter.ConvertSpanToRange(m.OriginalSpan, sourceText), m.GeneratedSpan - )).ToList(); + )).ToArray(); - var relevantTextSpan = relevantRange.ToTextSpan(razorCodeDocument.Source.Text); - var intersectingGeneratedSpans = sourceMappingRanges.Where(m => relevantRange.IntersectsOrTouches(m.Item1)).Select(m => m.GeneratedSpan).ToArray(); + // Find the spans in the generated C# code that correspond to the selected Razor code + var intersectingGeneratedSpans = sourceMappingRanges + .Where(m => m.OriginalRange != null && selectionRange.IntersectsOrTouches(m.OriginalRange)) + .Select(m => m.GeneratedSpan) + .ToArray(); - // I'm not sure why, but for some reason the endCharacterIndex is lower than the CharacterIndex so they must be swapped. - var intersectingGeneratedRanges = intersectingGeneratedSpans.Select(m => - new Range - { - Start = new Position(m.LineIndex, m.EndCharacterIndex), - End = new Position(m.LineIndex, m.CharacterIndex) - } - ).ToArray(); + var intersectingGeneratedRanges = intersectingGeneratedSpans + .Select(m =>RazorDiagnosticConverter.ConvertSpanToRange(m, generatedSourceText)) + .Where(range => range != null) + .Select(range => range!) + .ToArray(); var parameters = new GetSymbolicInfoParams() { @@ -603,163 +679,83 @@ private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashS Uri = componentUri }, HostDocumentVersion = version.Value, - IntersectingRangesInGeneratedMappings = intersectingGeneratedRanges + GeneratedDocumentRanges = intersectingGeneratedRanges }; - SymbolicInfo? componentInfo; + MemberSymbolicInfo? componentInfo; + + // Send a request to the language server to get symbolic information about the extracted code try { - componentInfo = await _clientConnection.SendRequestAsync( + componentInfo = await _clientConnection.SendRequestAsync( CustomMessageNames.RazorGetSymbolicInfoEndpointName, parameters, cancellationToken: default).ConfigureAwait(false); } catch (Exception ex) { - throw new InvalidOperationException("Failed to send request to RazorComponentInfoEndpoint", ex); + throw new InvalidOperationException("Failed to send request to Roslyn endpoint", ex); } if (componentInfo is null) { - inst.Free(); - return result; + sbInstance.Free(); + throw new InvalidOperationException("Roslyn endpoint 'GetSymbolicInfo' returned null"); } - var codeBlockAtEnd = GetCodeBlockAtEnd(syntaxTree); - if (codeBlockAtEnd is null) - { - inst.Free(); - return result; - } + // Generate parameter declarations for methods and attributes used in the extracted component + var promotedMethods = GeneratePromotedMethods(componentInfo.Methods); + var promotedAttributes = GeneratePromotedAttributes(componentInfo.Attributes, Path.GetFileName(componentUri.LocalPath)); + var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, promotedAttributes); - var identifiersInCodeBlock = GetIdentifiersInContext(codeBlockAtEnd, cSharpCodeBlocks); - if (componentInfo.Methods is null) + // Capitalize attribute references in the new component to match C# naming conventions + foreach (var attribute in componentInfo.Attributes) { - inst.Free(); - return result; + var capitalizedAttributeName = CapitalizeString(attribute.Name); + newFileContentBuilder.Replace(attribute.Name, capitalizedAttributeName); } - var methodsInFile = componentInfo.Methods.Select(method => method.Name).ToHashSet(); - var methodStringsInContext = methodsInFile.Intersect(identifiersInCodeBlock); - var methodsInContext = GetMethodsInContext(componentInfo, methodStringsInContext); - var promotedMethods = GeneratePromotedMethods(methodsInContext); - - var fieldsInContext = GetFieldsInContext(componentInfo.Fields, identifiersInCodeBlock); - var forwardedFields = GenerateForwardedConstantFields(fieldsInContext, Path.GetFileName(razorCodeDocument.Source.FilePath)); - - var newFileCodeBlock = GenerateNewFileCodeBlock(promotedMethods, forwardedFields); - - ReplaceMethodInvocations(newFileContentBuilder, methodsInContext); newFileContentBuilder.Append(newFileCodeBlock); result.NewContents = newFileContentBuilder.ToString(); - result.Methods = methodsInContext; + result.Methods = componentInfo.Methods; + result.Attributes = componentInfo.Attributes; - inst.Free(); + sbInstance.Free(); return result; } - private static List GetCSharpCodeBlocks(RazorSyntaxTree syntaxTree, int start, int end) + private static List GetCSharpCodeBlocks( + RazorSyntaxTree syntaxTree, + int start, + int end, + out CSharpStatementLiteralSyntax? atCodeBlock) { var root = syntaxTree.Root; var span = new TextSpan(start, end - start); - // Get only CSharpSyntaxNodes without Razor Directives as children or ancestors. This avoids getting the @code block at the end of a razor file. - var razorDirectives = root.DescendantNodes() - .Where(node => node.SpanStart >= start && node.Span.End <= end) - .OfType(); - + // Get only CSharpSyntaxNodes without Razor Meta Code as ancestors. This avoids getting the @code block at the end of a razor file. var cSharpCodeBlocks = root.DescendantNodes() .Where(node => span.Contains(node.Span)) - .OfType() - .Where(csharpNode => - !csharpNode.Ancestors().OfType().Any() && - !razorDirectives.Any(directive => directive.Span.Contains(csharpNode.Span))) + .OfType() + .Where(cSharpNode => + !cSharpNode.Ancestors().OfType().Any()) .ToList(); - return cSharpCodeBlocks; - } - - // Get identifiers in code block to union with the identifiers in the extracted code - private static HashSet GetIdentifiersInContext(SyntaxNode codeBlockAtEnd, List previousCodeBlocks) - { - var identifiersInLastCodeBlock = new HashSet(); - var identifiersInPreviousCodeBlocks = new HashSet(); - - if (codeBlockAtEnd == null) - { - return identifiersInLastCodeBlock; - } - - foreach (var node in codeBlockAtEnd.DescendantNodes()) - { - if (node.Kind is Language.SyntaxKind.Identifier) - { - var lit = node.ToFullString(); - identifiersInLastCodeBlock.Add(lit); - } - } - - foreach (var previousCodeBlock in previousCodeBlocks) - { - foreach (var node in previousCodeBlock.DescendantNodes()) - { - if (node.Kind is Language.SyntaxKind.Identifier) - { - var lit = node.ToFullString(); - identifiersInPreviousCodeBlocks.Add(lit); - } - } - } - - // Now union with identifiers in other cSharpCodeBlocks in context - identifiersInLastCodeBlock.IntersectWith(identifiersInPreviousCodeBlocks); + atCodeBlock = root.DescendantNodes().OfType().LastOrDefault(); + atCodeBlock = atCodeBlock is not null && cSharpCodeBlocks.Contains(atCodeBlock) ? null : atCodeBlock; - return identifiersInLastCodeBlock; - } - - private static HashSet GetMethodsInContext(SymbolicInfo componentInfo, IEnumerable methodStringsInContext) - { - var methodsInContext = new HashSet(); - if (componentInfo.Methods is null) - { - return methodsInContext; - } - - foreach (var componentMethod in componentInfo.Methods) - { - if (methodStringsInContext.Contains(componentMethod.Name) && !methodsInContext.Any(method => method.Name == componentMethod.Name)) - { - methodsInContext.Add(componentMethod); - } - } - - return methodsInContext; - } - - private static SyntaxNode? GetCodeBlockAtEnd(RazorSyntaxTree syntaxTree) - { - var root = syntaxTree.Root; - - // Get only the last CSharpCodeBlock (has an explicit "@code" transition) - var razorDirectiveAtEnd = root.DescendantNodes().OfType().LastOrDefault(); - - if (razorDirectiveAtEnd is null) - { - return null; - } - - return razorDirectiveAtEnd.Parent; + return cSharpCodeBlocks; } // Create a series of [Parameter] attributes for extracted methods. // Void return functions are promoted to Action delegates. // All other functions should be Func delegates. - private static string GeneratePromotedMethods(HashSet methods) + private static string GeneratePromotedMethods(MethodSymbolicInfo[] methods) { var builder = new StringBuilder(); var parameterCount = 0; - var totalMethods = methods.Count; + var totalMethods = methods.Length; foreach (var method in methods) { @@ -769,7 +765,7 @@ private static string GeneratePromotedMethods(HashSet method builder.AppendLine("[Parameter]"); // Start building delegate type - builder.Append("public "); + builder.Append("required public "); builder.Append(method.ReturnType == "void" ? "Action" : "Func"); // If delegate type is Action, only add generic parameters if needed. @@ -792,7 +788,7 @@ private static string GeneratePromotedMethods(HashSet method builder.Append('>'); } - builder.Append($"Parameter{(parameterCount > 0 ? parameterCount : "")} {{ get; set; }}"); + builder.Append($" {method.Name} {{ get; set; }}"); if (parameterCount < totalMethods - 1) { // Space between methods except for the last method. @@ -806,44 +802,27 @@ private static string GeneratePromotedMethods(HashSet method return builder.ToString(); } - private static HashSet GetFieldsInContext(FieldSymbolicInfo[] fields, HashSet identifiersInCodeBlock) - { - if (fields is null) - { - return []; - } - - var fieldsInContext = new HashSet(); - - foreach (var fieldInfo in fields) - { - if (identifiersInCodeBlock.Contains(fieldInfo.Name)) - { - fieldsInContext.Add(fieldInfo); - } - } - - return fieldsInContext; - } - - // By forwarded fields, I mean fields that are present in the extraction, but get directly added/copied to the extracted component's code block, instead of being passed as an attribute. - // If you have naming suggestions that make more sense, please let me know. - private static string GenerateForwardedConstantFields(HashSet relevantFields, string? sourceDocumentFileName) + private static string GeneratePromotedAttributes(AttributeSymbolicInfo[] relevantFields, string? sourceDocumentFileName) { var builder = new StringBuilder(); var fieldCount = 0; - var totalFields = relevantFields.Count; + var totalFields = relevantFields.Length; foreach (var field in relevantFields) { - if (field.IsValueType || field.Type == "string") + var capitalizedFieldName = CapitalizeString(field.Name); + + if ((field.IsValueType || field.Type == "string") && field.IsWrittenTo) { - builder.AppendLine($"// Warning: Field '{field.Name}' was passed by value and may not be referenced correctly. Please check its usage in the original document: '{sourceDocumentFileName}'."); + builder.AppendLine($"// Warning: Field '{capitalizedFieldName}' was passed by value and may not be referenced correctly. Please check its usage in the original document: '{sourceDocumentFileName}'."); } - builder.AppendLine($"public {field.Type} {field.Name}"); + builder.AppendLine("[Parameter]"); + + // Members cannot be less visible than their enclosing type, so we don't need to check for private fields. + builder.AppendLine($"required public {field.Type} {capitalizedFieldName} {{ get; set; }}"); - if (fieldCount < totalFields - 1) + if (fieldCount++ < totalFields - 1) { builder.AppendLine(); } @@ -852,46 +831,45 @@ private static string GenerateForwardedConstantFields(HashSet return builder.ToString(); } - private static string GenerateNewFileCodeBlock(string promotedMethods, string carryoverFields) + // Most likely out of scope for the class, could be moved elsewhere + private static string CapitalizeString(string str) + { + return str.Length > 0 + ? char.ToUpper(str[0]) + str[1..] + : str; + } + + private static string GenerateNewFileCodeBlock(string promotedMethods, string promotedProperties) { var builder = new StringBuilder(); builder.AppendLine(); builder.AppendLine(); builder.AppendLine("@code {"); - builder.AppendLine(carryoverFields); + builder.AppendLine(promotedProperties); builder.AppendLine(promotedMethods); builder.AppendLine("}"); return builder.ToString(); } - // Method invocations in the new file must be replaced with their respective parameter name. - private static void ReplaceMethodInvocations(StringBuilder newFileContentBuilder, HashSet methods) + private static string GenerateComponentNameAndParameters(MethodSymbolicInfo[]? methods, AttributeSymbolicInfo[]? attributes, string componentName) { - var parameterCount = 0; - foreach (var method in methods) + if (methods is null || attributes is null) { - newFileContentBuilder.Replace(method.Name, $"Parameter{(parameterCount > 0 ? parameterCount : "")}"); - parameterCount++; + return componentName; } - } - private static string GenerateComponentNameAndParameters(HashSet? methods, string componentName) - { var builder = new StringBuilder(); builder.Append(componentName + " "); - var parameterCount = 0; - if (methods is null) + foreach (var method in methods) { - return builder.ToString(); + builder.Append($"{method.Name}={method.Name} "); } - foreach (var method in methods) + foreach (var attribute in attributes) { - builder.Append($"Parameter{(parameterCount > 0 ? parameterCount : "")}"); - builder.Append($"={method.Name}"); - builder.Append(' '); - parameterCount++; + var capitalizedAttributeName = CapitalizeString(attribute.Name); + builder.Append($"{capitalizedAttributeName}={attribute.Name} "); } return builder.ToString(); @@ -900,6 +878,7 @@ private static string GenerateComponentNameAndParameters(HashSet? Methods { get; set; } + public MethodSymbolicInfo[]? Methods { get; set; } + public AttributeSymbolicInfo[]? Attributes { get; set; } } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs index 30c36b52cc9..6a45afb5ca8 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/CodeActions/GetSymbolicInfoParams.cs @@ -23,16 +23,15 @@ internal record GetSymbolicInfoParams [JsonPropertyName("hostDocumentVersion")] public required int HostDocumentVersion { get; set; } - [DataMember(Name = "intersectingRangesInGeneratedMappings")] - [JsonPropertyName("intersectingRangesInGeneratedMappings")] - - public required Range[] IntersectingRangesInGeneratedMappings { get; set; } + [DataMember(Name = "generatedDocumentRanges")] + [JsonPropertyName("generatedDocumentRanges")] + public required Range[] GeneratedDocumentRanges { get; set; } } -internal sealed record SymbolicInfo +internal sealed record MemberSymbolicInfo { public required MethodSymbolicInfo[] Methods { get; set; } - public required FieldSymbolicInfo[] Fields { get; set; } + public required AttributeSymbolicInfo[] Attributes { get; set; } } internal sealed record MethodSymbolicInfo @@ -44,7 +43,7 @@ internal sealed record MethodSymbolicInfo public required string[] ParameterTypes { get; set; } } -internal sealed record FieldSymbolicInfo +internal sealed record AttributeSymbolicInfo { public required string Name { get; set; } public required string Type { get; set; } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs index f813ef24a2d..b1db913a84b 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Endpoints/RazorCustomMessageTarget_GetSymbolicInfo.cs @@ -14,7 +14,7 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient.Endpoints; internal partial class RazorCustomMessageTarget { [JsonRpcMethod(CustomMessageNames.RazorGetSymbolicInfoEndpointName, UseSingleObjectParameterDeserialization = true)] - public async Task RazorGetSymbolicInfoAsync(GetSymbolicInfoParams request, CancellationToken cancellationToken) + public async Task RazorGetSymbolicInfoAsync(GetSymbolicInfoParams request, CancellationToken cancellationToken) { var (synchronized, virtualDocumentSnapshot) = await TrySynchronizeVirtualDocumentAsync(request.HostDocumentVersion, request.Document, cancellationToken); if (!synchronized || virtualDocumentSnapshot is null) @@ -23,11 +23,11 @@ internal partial class RazorCustomMessageTarget } request.Document.Uri = virtualDocumentSnapshot.Uri; - ReinvokeResponse response; + ReinvokeResponse response; try { - response = await _requestInvoker.ReinvokeRequestOnServerAsync( + response = await _requestInvoker.ReinvokeRequestOnServerAsync( RazorLSPConstants.RoslynGetSymbolicInfoEndpointName, RazorLSPConstants.RazorCSharpLanguageServerName, request, diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs index 938349e20b7..9d1541339c6 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.ExtractToComponent.NetFx.cs @@ -3,41 +3,21 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Components; -using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; -using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; -using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.ExternalAccess.Razor; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; -using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Testing; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.Copilot.Internal; using Microsoft.VisualStudio.LanguageServer.Protocol; using Moq; using Roslyn.Test.Utilities; using Xunit; -using Xunit.Abstractions; -using Xunit.Sdk; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; @@ -336,55 +316,6 @@ await ValidateExtractComponentCodeActionAsync( codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } - - [Fact (Skip = "Need to figure out how to use additionalRazorFiles")] - public async Task Handle_ExtractComponent_MarkupWithUsings_ReturnsResult() - { - var input = """ -
- -
- """; - - var book = """ - @namespace BlazorApp1.Shared - -

Title: @Title

-

Written by @Author in @Year

- - - @code { - [Parameter] - public string Title { get; set; } - - [Parameter] - public string Author { get; set; } - - [Parameter] - public string Year { get; set; } - } - """; - - var expectedRazorComponent = """ -
- -
- """; - - var additionalRazorDocuments = new[] - { - ("Book.razor", book) - }; - - await ValidateExtractComponentCodeActionAsync( - input, - expectedRazorComponent, - ExtractToComponentTitle, - additionalRazorDocuments: additionalRazorDocuments, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], - codeActionResolversCreator: CreateExtractComponentCodeActionResolver); - } - private async Task ValidateExtractComponentCodeActionAsync( string input, string? expected,