File: src\Analyzers\CSharp\CodeFixes\UsePrimaryConstructor\CSharpUsePrimaryConstructorCodeFixProvider.cs
Web Access
Project: src\src\Features\CSharp\Portable\Microsoft.CodeAnalysis.CSharp.Features.csproj (Microsoft.CodeAnalysis.CSharp.Features)
// 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 Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CSharp.UsePrimaryConstructor;
 
using static CSharpUsePrimaryConstructorDiagnosticAnalyzer;
using static CSharpSyntaxTokens;
using static SyntaxFactory;
 
[ExportCodeFixProvider(LanguageNames.CSharp, Name = PredefinedCodeFixProviderNames.UsePrimaryConstructor), Shared]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal sealed partial class CSharpUsePrimaryConstructorCodeFixProvider() : CodeFixProvider
{
    public override ImmutableArray<string> FixableDiagnosticIds
        => [IDEDiagnosticIds.UsePrimaryConstructorDiagnosticId];
 
    public override FixAllProvider? GetFixAllProvider()
#if CODE_STYLE
        => WellKnownFixAllProviders.BatchFixer;
#else
        => new CSharpUsePrimaryConstructorFixAllProvider();
#endif
 
    public override 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);
            }
        }
 
        return Task.CompletedTask;
    }
 
    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);
 
                return 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;
 
        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);
                }
            }
        }
    }
}