/
githubmirror
/
roslyn
Обзор
Документация
Войти
/
githubmirror
/
roslyn
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/Analyzers/CSharp/CodeFixes/UsePrimaryConstructor/CSharpUsePrimaryConstructorCodeFixProvider.cs
639 строк
31 KB
Cyrus Najmabadi
Reapply "Update methods to be `async`." (#81808)
31 дек 2025, 02:04
31 дек 2025, 02:04
ba729ad
Код
Авторство
О чём код?
// 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. // Ignore Spelling: loc kvp using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Composition; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp.Extensions; using Microsoft.CodeAnalysis.CSharp.LanguageService; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Editing; using Microsoft.CodeAnalysis.FindSymbols; using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.Utilities; using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.CSharp.UsePrimaryConstructor; using static CSharpSyntaxTokens; using static CSharpUsePrimaryConstructorDiagnosticAnalyzer; using static SyntaxFactory; [ExportCodeFixProvider(LanguageNames.CSharp, Name = PredefinedCodeFixProviderNames.UsePrimaryConstructor), Shared] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] internal sealed partial class CSharpUsePrimaryConstructorCodeFixProvider() : CodeFixProvider { private static readonly Matcher<SyntaxTrivia> s_commentFollowedByBlankLine = Matcher.Sequence( Matcher.Single<SyntaxTrivia>(t => t.IsSingleOrMultiLineComment(), "comment"), Matcher.Single<SyntaxTrivia>(t => t.Kind() == SyntaxKind.EndOfLineTrivia, "first end of line"), Matcher.Repeat(Matcher.Single<SyntaxTrivia>(t => t.Kind() == SyntaxKind.WhitespaceTrivia, "whitespace")), Matcher.Single<SyntaxTrivia>(t => t.IsKind(SyntaxKind.EndOfLineTrivia), "second end of line")); public override ImmutableArray<string> FixableDiagnosticIds => [IDEDiagnosticIds.UsePrimaryConstructorDiagnosticId]; public override FixAllProvider? GetFixAllProvider() #if CODE_STYLE => WellKnownFixAllProviders.BatchFixer; #else => new CSharpUsePrimaryConstructorFixAllProvider(); #endif public override async Task RegisterCodeFixesAsync(CodeFixContext context) { var cancellationToken = context.CancellationToken; var document = context.Document; foreach (var diagnostic in context.Diagnostics) { if (diagnostic.Location.FindNode(cancellationToken) is not ConstructorDeclarationSyntax constructorDeclaration) continue; var properties = diagnostic.Properties; var additionalNodes = diagnostic.AdditionalLocations; context.RegisterCodeFix( CodeAction.Create( CSharpAnalyzersResources.Use_primary_constructor, cancellationToken => UsePrimaryConstructorAsync(document, constructorDeclaration, properties, removeMembers: false, cancellationToken), nameof(CSharpAnalyzersResources.Use_primary_constructor)), diagnostic); if (diagnostic.Properties.Count > 0) { var resource = diagnostic.Properties.ContainsKey(AllFieldsName) ? CSharpCodeFixesResources.Use_primary_constructor_and_remove_fields : diagnostic.Properties.ContainsKey(AllPropertiesName) ? CSharpCodeFixesResources.Use_primary_constructor_and_remove_properties : CSharpCodeFixesResources.Use_primary_constructor_and_remove_members; context.RegisterCodeFix( CodeAction.Create( resource, cancellationToken => UsePrimaryConstructorAsync(document, constructorDeclaration, properties, removeMembers: true, cancellationToken), nameof(CSharpCodeFixesResources.Use_primary_constructor_and_remove_members)), diagnostic); } } } private static async Task<Solution> UsePrimaryConstructorAsync( Document document, ConstructorDeclarationSyntax constructorDeclaration, ImmutableDictionary<string, string?> properties, bool removeMembers, CancellationToken cancellationToken) { var solutionEditor = new SolutionEditor(document.Project.Solution); await UsePrimaryConstructorAsync( solutionEditor, document, constructorDeclaration, properties, removeMembers, cancellationToken).ConfigureAwait(false); return solutionEditor.GetChangedSolution(); } private static async Task UsePrimaryConstructorAsync( SolutionEditor solutionEditor, Document document, ConstructorDeclarationSyntax constructorDeclaration, ImmutableDictionary<string, string?> properties, bool removeMembers, CancellationToken cancellationToken) { var solution = document.Project.Solution; var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false); var typeDeclaration = (TypeDeclarationSyntax)constructorDeclaration.GetRequiredParent(); var namedType = semanticModel.GetRequiredDeclaredSymbol(typeDeclaration, cancellationToken); var constructor = semanticModel.GetRequiredDeclaredSymbol(constructorDeclaration, cancellationToken); // If we're removing members, first go through and update all references to that member to use the parameter name. var typeDeclarationNodes = namedType.DeclaringSyntaxReferences.Select(r => (TypeDeclarationSyntax)r.GetSyntax(cancellationToken)); var namedTypeDocuments = typeDeclarationNodes.Select(r => solution.GetRequiredDocument(r.SyntaxTree)).ToImmutableHashSet(); var removedMembers = await RemoveMembersAsync().ConfigureAwait(false); // If the constructor has a base-initializer, then go find the base-type in the inheritance list for the // typedecl and move it there. await MoveBaseConstructorArgumentsAsync().ConfigureAwait(false); // Then take all the assignments in the constructor, and place them directly on the field/property initializers. await ProcessConstructorAssignmentsAsync().ConfigureAwait(false); // Then remove the constructor itself. var constructorDocumentEditor = await solutionEditor.GetDocumentEditorAsync(document.Id, cancellationToken).ConfigureAwait(false); constructorDocumentEditor.RemoveNode(constructorDeclaration, GetConstructorRemovalOptions()); // When moving the parameter list from the constructor to the type, we will no longer have nested types or // member constants in scope. So rewrite references to them if that's the case. var updatedParameterList = GenerateFinalParameterList(); // Finally move the constructors parameter list to the type declaration. constructorDocumentEditor.ReplaceNode( typeDeclaration, (current, generator) => { var currentTypeDeclaration = (TypeDeclarationSyntax)current; // Move the whitespace that is current after the name (or type args) to after the parameter list. var typeParameterList = currentTypeDeclaration.TypeParameterList; var triviaAfterName = typeParameterList != null ? typeParameterList.GetTrailingTrivia() : currentTypeDeclaration.Identifier.GetAllTrailingTrivia(); var finalAttributeLists = currentTypeDeclaration.AttributeLists.AddRange( constructorDeclaration.AttributeLists.Select( a => a.WithTarget(AttributeTargetSpecifier(MethodKeyword)).WithoutTrivia().WithAdditionalAnnotations(Formatter.Annotation))); var finalTrivia = CreateFinalTypeDeclarationLeadingTrivia( currentTypeDeclaration, constructorDeclaration, constructor, properties, removedMembers); var finalTypeDeclaration = currentTypeDeclaration .WithAttributeLists(finalAttributeLists) .WithLeadingTrivia(finalTrivia) .WithIdentifier(typeParameterList != null ? currentTypeDeclaration.Identifier : currentTypeDeclaration.Identifier.WithoutTrailingTrivia()) .WithTypeParameterList(typeParameterList?.WithoutTrailingTrivia()) .WithParameterList(updatedParameterList .WithoutLeadingTrivia() .WithTrailingTrivia(triviaAfterName) .WithAdditionalAnnotations(Formatter.Annotation)); return WithCommentMoved(finalTypeDeclaration); }); return; TypeDeclarationSyntax WithCommentMoved(TypeDeclarationSyntax finalTypeDeclaration) { var firstMember = typeDeclaration.Members.First(); if (firstMember == constructorDeclaration || removedMembers.Any(kvp => kvp.Value.memberNode == firstMember)) { // We're removing the first member in the type. If this member had comments above it (with a blank line // between it and the member) then keep those comments around. var triviaToMove = GetLeadingCommentTrivia(firstMember); if (triviaToMove.Length > 0) { var nextToken = finalTypeDeclaration.OpenBraceToken.GetNextToken(); return finalTypeDeclaration.ReplaceToken( nextToken, nextToken.WithPrependedLeadingTrivia(triviaToMove)); } } return finalTypeDeclaration; } ImmutableArray<SyntaxTrivia> GetLeadingCommentTrivia(MemberDeclarationSyntax firstMember) { var leadingTrivia = firstMember.GetLeadingTrivia().ToImmutableArray(); for (var i = leadingTrivia.Length - 1; i >= 0; i--) { var currentIndex = i; if (s_commentFollowedByBlankLine.TryMatch(leadingTrivia, ref currentIndex)) return leadingTrivia[..currentIndex]; } return []; } SyntaxRemoveOptions GetConstructorRemovalOptions() { // if we're removing all the members prior to the constructor, and any of those member had pragmas we are // keeping, then we need to keep it on the constructor as well. var constructorRemoveOptions = GetRemoveOptions(constructorDeclaration); if (constructorRemoveOptions == SyntaxGenerator.DefaultRemoveOptions) { for (var currentIndex = typeDeclaration.Members.IndexOf(constructorDeclaration) - 1; currentIndex >= 0; currentIndex--) { var priorMember = typeDeclaration.Members[currentIndex]; // Hit a member we're not removing. Just use the default options for the constructor. if (!removedMembers.Any(kvp => kvp.Value.memberNode == priorMember)) break; // Check if we had special options when removing the field/prop. We want to apply that to the // constructor as well. var memberRemoveOptions = GetRemoveOptions(priorMember); if (memberRemoveOptions != SyntaxGenerator.DefaultRemoveOptions) return memberRemoveOptions; } } return constructorRemoveOptions; } ParameterListSyntax GenerateFinalParameterList() { // Note: we can use constructorDeclarationSemanticModel as we're only touching nodes within the constructor // declaration itself. var updatedParameterList = UpdateReferencesToNestedMembers(constructorDeclaration.ParameterList); updatedParameterList = RemoveElementIndentation( typeDeclaration, constructorDeclaration, updatedParameterList, static list => list.Parameters); updatedParameterList = RemoveInModifierIfMemberIsRemoved(updatedParameterList); return updatedParameterList; } ParameterListSyntax RemoveInModifierIfMemberIsRemoved(ParameterListSyntax parameterList) { if (!removeMembers) return parameterList; return parameterList.ReplaceNodes( parameterList.Parameters, (_, current) => { var inKeyword = current.Modifiers.FirstOrDefault(t => t.Kind() == SyntaxKind.InKeyword); if (inKeyword == default) return current; // remove the 'in' modifier if we're removing the field. Captures can't refer to an in-parameter. if (!properties.Values.Any(v => v == current.Identifier.ValueText)) return current; return current.WithModifiers(current.Modifiers.Remove(inKeyword)).WithTriviaFrom(current); }); } ParameterListSyntax UpdateReferencesToNestedMembers(ParameterListSyntax parameterList) { return parameterList.ReplaceNodes( parameterList.DescendantNodes().OfType<SimpleNameSyntax>(), (nameSyntax, currentNameSyntax) => { if (nameSyntax.Parent is QualifiedNameSyntax qualifiedNameSyntax) { // Don't have to update if the name is already the RHS of some qualified name. if (qualifiedNameSyntax.Left == nameSyntax) { // Qualified names occur in things like the `type` portion of the parameter return TryQualify(nameSyntax, currentNameSyntax); } } else if (nameSyntax.Parent is MemberAccessExpressionSyntax memberAccessExpression) { // Don't have to update if the name is already the RHS of some member access expr. if (memberAccessExpression.Expression == nameSyntax) { // Member access expressions occur in things like the default initializer, or attribute // arguments of the parameter. return TryQualify(nameSyntax, currentNameSyntax); } } else { // Standalone name. Try to qualify depending on if this is a type or member context. return TryQualify(nameSyntax, currentNameSyntax); } return currentNameSyntax; }); SyntaxNode TryQualify( SimpleNameSyntax originalName, SimpleNameSyntax currentName) { var symbol = semanticModel.GetSymbolInfo(originalName, cancellationToken).GetAnySymbol(); return symbol switch { INamedTypeSymbol { ContainingType: { } containingType } => CreateDottedName(originalName, currentName, containingType), IMethodSymbol or IPropertySymbol or IEventSymbol or IFieldSymbol => symbol is { ContainingType.OriginalDefinition: { } containingType } && namedType.Equals(containingType) ? CreateDottedName(originalName, currentName, containingType) : currentName, _ => currentName, }; } SyntaxNode CreateDottedName( SimpleNameSyntax originalName, SimpleNameSyntax currentName, INamedTypeSymbol containingType) { var containingTypeSyntax = containingType.GenerateNameSyntax(); return SyntaxFacts.IsInTypeOnlyContext(originalName) ? QualifiedName(containingTypeSyntax, currentName) : MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, containingTypeSyntax, currentName); } } static TListSyntax RemoveElementIndentation<TListSyntax>( TypeDeclarationSyntax typeDeclaration, ConstructorDeclarationSyntax constructorDeclaration, TListSyntax list, Func<TListSyntax, IEnumerable<SyntaxNode>> getElements) where TListSyntax : SyntaxNode { // Since we're moving parameters from the constructor to the type, attempt to dedent them if appropriate. var typeLeadingWhitespace = GetLeadingWhitespace(typeDeclaration); var constructorLeadingWhitespace = GetLeadingWhitespace(constructorDeclaration); if (constructorLeadingWhitespace.Length > typeLeadingWhitespace.Length && constructorLeadingWhitespace.StartsWith(typeLeadingWhitespace)) { var indentation = constructorLeadingWhitespace[typeLeadingWhitespace.Length..]; return list.ReplaceNodes( getElements(list), (p, _) => { var elementLeadingWhitespace = GetLeadingWhitespace(p); if (elementLeadingWhitespace.EndsWith(indentation)) { var leadingTrivia = p.GetLeadingTrivia(); return p.WithLeadingTrivia( leadingTrivia.Take(leadingTrivia.Count - 1).Concat(Whitespace(elementLeadingWhitespace[..^indentation.Length]))); } return p; }); } return list; } static string GetLeadingWhitespace(SyntaxNode node) => node.GetLeadingTrivia() is [.., (kind: SyntaxKind.WhitespaceTrivia) whitespace] ? whitespace.ToString() : ""; async ValueTask MoveBaseConstructorArgumentsAsync() { if (constructorDeclaration.Initializer is null) return; // Note: the primary constructor parameters can only be passed to the base class on the same type // declaration that the primary constructor is on. var documentEditor = await solutionEditor.GetDocumentEditorAsync(document.Id, cancellationToken).ConfigureAwait(false); var argumentList = RemoveElementIndentation( typeDeclaration, constructorDeclaration, constructorDeclaration.Initializer.ArgumentList, static list => list.Arguments); if (typeDeclaration.BaseList is { Types: [SimpleBaseTypeSyntax baseType, ..] } && semanticModel.GetSymbolInfo(baseType.Type, cancellationToken).GetAnySymbol() is INamedTypeSymbol { TypeKind: TypeKind.Class }) { // Case 1: The type already explicitly lists the base type on the current type decl. If so, move the arguments to it. // For example: // // `class C : B, I` becomes `class C(int i) : B(i), I` documentEditor.ReplaceNode( baseType, PrimaryConstructorBaseType(baseType.Type.WithoutTrailingTrivia(), argumentList.WithoutLeadingTrivia()) .WithTrailingTrivia(baseType.GetTrailingTrivia())); } else { // Case 2: The type doesn't have the base type on this declaration. We'll have to synthesize it and add it to the base list. // For example: // // `class C : I` becomes `class C(int i) : B(i), I` var baseTypeSymbol = namedType.BaseType; if (baseTypeSymbol is null) return; var synthesizedTypeNode = baseTypeSymbol.GenerateNameSyntax(allowVar: false); var baseTypeSyntax = PrimaryConstructorBaseType(synthesizedTypeNode, argumentList); documentEditor.ReplaceNode( typeDeclaration, (current, _) => { var currentTypeDeclaration = (TypeDeclarationSyntax)current; if (currentTypeDeclaration.BaseList is null) { var typeParameterList = currentTypeDeclaration.TypeParameterList; var triviaAfterName = typeParameterList != null ? typeParameterList.GetTrailingTrivia() : currentTypeDeclaration.Identifier.GetAllTrailingTrivia(); return currentTypeDeclaration .WithIdentifier(currentTypeDeclaration.Identifier.WithoutTrailingTrivia()) .WithTypeParameterList(typeParameterList?.WithoutTrailingTrivia()) .WithBaseList(BaseList([baseTypeSyntax]).WithLeadingTrivia(Space).WithTrailingTrivia(triviaAfterName)); } else { return currentTypeDeclaration.WithBaseList( currentTypeDeclaration.BaseList.WithTypes(currentTypeDeclaration.BaseList.Types.Insert(0, baseTypeSyntax))); } }); } } async ValueTask ProcessConstructorAssignmentsAsync() { if (constructorDeclaration.ExpressionBody is not null) { // Validated by analyzer. await ProcessConstructorAssignmentAsync( (AssignmentExpressionSyntax)constructorDeclaration.ExpressionBody.Expression, expressionStatement: null).ConfigureAwait(false); } else { Contract.ThrowIfNull(constructorDeclaration.Body); foreach (var statement in constructorDeclaration.Body.Statements) { // Validated by analyzer. var expressionStatement = (ExpressionStatementSyntax)statement; await ProcessConstructorAssignmentAsync( (AssignmentExpressionSyntax)expressionStatement.Expression, expressionStatement).ConfigureAwait(false); } } } async ValueTask ProcessConstructorAssignmentAsync( AssignmentExpressionSyntax assignmentExpression, ExpressionStatementSyntax? expressionStatement) { var member = semanticModel.GetSymbolInfo(assignmentExpression.Left, cancellationToken).GetAnySymbol()?.OriginalDefinition; // Validated by analyzer. Contract.ThrowIfFalse(member is IFieldSymbol or IPropertySymbol); // no point updating the member if it's going to be removed. if (removedMembers.ContainsKey(member)) return; var declaration = member.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken); var declarationDocument = solution.GetRequiredDocument(declaration.SyntaxTree); var declarationDocumentEditor = await solutionEditor.GetDocumentEditorAsync(declarationDocument.Id, cancellationToken).ConfigureAwait(false); declarationDocumentEditor.ReplaceNode( declaration, UpdateDeclaration(declaration, assignmentExpression, expressionStatement).WithAdditionalAnnotations(Formatter.Annotation)); } SyntaxNode UpdateDeclaration(SyntaxNode declaration, AssignmentExpressionSyntax assignmentExpression, ExpressionStatementSyntax? expressionStatement) { var newLeadingTrivia = assignmentExpression.Left.GetTrailingTrivia(); var initializer = EqualsValueClause(assignmentExpression.OperatorToken, assignmentExpression.Right); if (declaration is VariableDeclaratorSyntax declarator) { return declarator .WithIdentifier(declarator.Identifier.WithTrailingTrivia(newLeadingTrivia)) .WithInitializer(initializer); } else if (declaration is PropertyDeclarationSyntax propertyDeclaration) { return propertyDeclaration .WithoutTrailingTrivia() .WithInitializer(initializer.WithLeadingTrivia(newLeadingTrivia)) .WithSemicolonToken( // Use existing semicolon if we have it. Otherwise create a fresh one and place existing // trailing trivia after it. expressionStatement?.SemicolonToken ?? SemicolonToken.WithTrailingTrivia(propertyDeclaration.GetTrailingTrivia())); } else { throw ExceptionUtilities.Unreachable(); } } async ValueTask<ImmutableDictionary<ISymbol, (MemberDeclarationSyntax memberNode, SyntaxNode nodeToRemove)>> RemoveMembersAsync() { var removedMembers = ImmutableDictionary<ISymbol, (MemberDeclarationSyntax memberNode, SyntaxNode nodeToRemove)>.Empty; if (removeMembers) { // Go through each pair of member/parameterName. Update all references to member to now refer to // parameterName. This is safe as the analyzer ensured that all existing locations would safely be able // to do this. Then once those are all done, actually remove the members. foreach (var (memberName, parameterName) in properties) { Contract.ThrowIfNull(parameterName); var (member, memberNode, nodeToRemove) = GetMemberToRemove(memberName); if (member is null) continue; removedMembers = removedMembers.Add(member, (memberNode, nodeToRemove)); await ReplaceReferencesToMemberWithParameterAsync( member, CSharpSyntaxFacts.Instance.EscapeIdentifier(parameterName)).ConfigureAwait(false); } foreach (var group in removedMembers.Values.GroupBy(n => n.memberNode.SyntaxTree)) { var syntaxTree = group.Key; var memberDocument = solution.GetRequiredDocument(syntaxTree); var documentEditor = await solutionEditor.GetDocumentEditorAsync(memberDocument.Id, cancellationToken).ConfigureAwait(false); foreach (var (memberNode, nodeToRemove) in group) { // Preserve pragmas around fields as they can affect more than just the field itself (they // extend to the rest of the file). documentEditor.RemoveNode(nodeToRemove, GetRemoveOptions(memberNode)); } } } return removedMembers; } static SyntaxRemoveOptions GetRemoveOptions(MemberDeclarationSyntax memberDeclaration) => memberDeclaration.GetLeadingTrivia().Any(t => t.GetStructure()?.Kind() == SyntaxKind.PragmaWarningDirectiveTrivia) ? SyntaxRemoveOptions.KeepDirectives : SyntaxGenerator.DefaultRemoveOptions; (ISymbol? member, MemberDeclarationSyntax memberNode, SyntaxNode nodeToRemove) GetMemberToRemove(string memberName) { foreach (var member in namedType.GetMembers(memberName)) { if (IsViableMemberToAssignTo(namedType, member, out var memberNode, out var nodeToRemove, cancellationToken)) return (member, memberNode, nodeToRemove); } return default; } async ValueTask ReplaceReferencesToMemberWithParameterAsync(ISymbol member, string parameterName) { var parameterNameNode = IdentifierName(ParseToken(parameterName)); // find all the references to member within this project. We can immediately filter down just to the // documents containing our named type. var references = await SymbolFinder.FindReferencesAsync( member, solution, namedTypeDocuments, cancellationToken).ConfigureAwait(false); using var _1 = PooledHashSet<SyntaxNode>.GetInstance(out var nodesToReplace); using var _2 = PooledHashSet<XmlEmptyElementSyntax>.GetInstance(out var seeTagsToReplace); foreach (var reference in references) { foreach (var location in reference.Locations) { if (location.IsImplicit) continue; if (location.Location.FindNode(findInsideTrivia: true, getInnermostNodeForTie: true, cancellationToken) is not IdentifierNameSyntax identifier) continue; var xmlElement = identifier.AncestorsAndSelf().OfType<XmlEmptyElementSyntax>().FirstOrDefault(); if (xmlElement is { Name.LocalName.ValueText: "see" }) { // reference to member in a `<see cref="name"/>` tag. Switch to a paramref tag instead. seeTagsToReplace.Add(xmlElement); } else if (identifier.IsRightSideOfDot()) { if (identifier.GetRequiredParent() is ExpressionSyntax expression) nodesToReplace.Add(expression); } else { nodesToReplace.Add(identifier); } } } foreach (var group in nodesToReplace.GroupBy(n => n.SyntaxTree)) { var document = solution.GetDocument(group.Key); if (document is null) continue; var documentEditor = await solutionEditor.GetDocumentEditorAsync(document.Id, cancellationToken).ConfigureAwait(false); foreach (var nodeToReplace in group) { documentEditor.ReplaceNode( nodeToReplace, parameterNameNode.WithTriviaFrom(nodeToReplace)); } } foreach (var group in seeTagsToReplace.GroupBy(n => n.SyntaxTree)) { var document = solution.GetDocument(group.Key); if (document is null) continue; var documentEditor = await solutionEditor.GetDocumentEditorAsync(document.Id, cancellationToken).ConfigureAwait(false); foreach (var seeTag in group) { var paramRefTag = seeTag .ReplaceToken(seeTag.Name.LocalName, Identifier("paramref").WithTriviaFrom(seeTag.Name.LocalName)) .WithAttributes([XmlNameAttribute(parameterName)]); documentEditor.ReplaceNode(seeTag, paramRefTag); } } } } }