.netroslyn-code-analysis

Roslyn Renamer does not rename method invokation on interface


I have a small CLI Tool to process code generated by a WCF connected service. The problem is that all operations and messages in the WSDL are named in camelCase. So I want to rename them to PascalCase. This works fine although it's pretty slow. There is just one problem. I use the Roslyn Microsoft.CodeAnalysis.Rename.Renamer. When I rename the symbol of a method declaration (in this case in an interface) the calls of this method are not renamed. They stay in camelCase.

Minimal Reproducible Example:

using System.Text.RegularExpressions;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Rename;

public static class Program {
    public static async Task Main(string[] args) {
        var code = """
            namespace Test {
                public class input {
                    public object data { get; set; }
                }

                public class serviceResponse {
                    public object data { get; set; }
                }

                public interface web {
                    System.Threading.Tasks.Task<Test.serviceResponse> getDataAsync(Test.input request);
                }

                public interface webChannel : Test.web, System.ServiceModel.IClientChannel {

                }

                public partial class webClient : System.ServiceModel.ClientBase<Test.web>, Test.web {
                    public System.Threading.Tasks.Task<Test.serviceResponse> getDataAsync(Test.input request)
                    {
                        return base.Channel.getDataAsync(request);
                    }
                }
            }
            """;

        var renamedCode = await RenameIdentifiersToPascalCaseAsync(code);
        Console.WriteLine(renamedCode);
    }

    public static async Task<string> RenameIdentifiersToPascalCaseAsync(string sourceCode) {
        const string annotationKey = "FullyQualifiedName";
        var preparationSyntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
        var preparationSyntaxTreeRoot = await preparationSyntaxTree.GetRootAsync();
        var nodesToReplace = preparationSyntaxTreeRoot.DescendantNodes()
            .Where(node => node is BaseTypeDeclarationSyntax or MethodDeclarationSyntax or PropertyDeclarationSyntax)
            .ToList();

        var preparedRootNode = preparationSyntaxTreeRoot
            .ReplaceNodes(nodesToReplace, (oldNode, newNode) => newNode.WithAdditionalAnnotations(new SyntaxAnnotation(annotationKey, GetFullyQualifiedName(newNode))));

        var document = new AdhocWorkspace().CurrentSolution.AddProject("TempProject", "TempAssembly", LanguageNames.CSharp).AddDocument("TempDocument", preparedRootNode);
        var project = document.Project;
        var solution = project.Solution;

        var semanticModel = await document.GetSemanticModelAsync();
        var syntaxTree = semanticModel!.SyntaxTree;

        var options = new SymbolRenameOptions();

        var typesDeclarations = syntaxTree.GetRoot().DescendantNodes().OfType<BaseTypeDeclarationSyntax>().ToList();
        Console.WriteLine("Renaming Types, Properties and Methods to Pascal Case...");

        for (var i = 0; i < typesDeclarations.Count; i++)
        {
            var typeDeclaration = typesDeclarations[i];

            var membersToRename = new List<MemberDeclarationSyntax> { typeDeclaration };
            membersToRename.AddRange(typeDeclaration.DescendantNodes().OfType<PropertyDeclarationSyntax>().ToList());
            membersToRename.AddRange(typeDeclaration.DescendantNodes().OfType<MethodDeclarationSyntax>().ToList());

            Console.WriteLine($"{(i + 1).ToString("D" + typesDeclarations.Count.ToString().Length)}/{typesDeclarations.Count} - Renaming {typeDeclaration.Identifier.Text}");
            foreach (var member in membersToRename)
            {
                semanticModel = await solution.Projects.First().Documents.First().GetSemanticModelAsync();

                var memberFullyQualifiedName = member.GetAnnotations(annotationKey).First().Data;
                var newMemberInstance = (await semanticModel!.SyntaxTree.GetRootAsync())
                    .DescendantNodesAndSelf()
                    .First(node => node.GetAnnotations(annotationKey).Any(a => a.Data == memberFullyQualifiedName));

                var typeSymbol = semanticModel.GetDeclaredSymbol(newMemberInstance);

                if (typeSymbol != null)
                {
                    var newName = typeSymbol.Name.ToPascalCase();
                    solution = await Renamer.RenameSymbolAsync(solution, typeSymbol, options, newName);
                }
            }
        }

        var newDocument = solution.Projects.Single().Documents.Single();
        var newSourceCode = (await newDocument!.GetSyntaxRootAsync())!.ToFullString();
        return newSourceCode;
    }
    
    private static string GetFullyQualifiedName(SyntaxNode node)
    {
        var name = node switch
        {
            NamespaceDeclarationSyntax => (node as NamespaceDeclarationSyntax)!.Name.ToString(),
            BaseTypeDeclarationSyntax => (node as BaseTypeDeclarationSyntax)!.Identifier.ToString(),
            PropertyDeclarationSyntax => (node as PropertyDeclarationSyntax)!.Identifier.ToString(),
            MethodDeclarationSyntax => (node as MethodDeclarationSyntax)!.Identifier.ToString(),
            _ => throw new ArgumentException(nameof(node))
        };

        if (node.Parent is null || node is NamespaceDeclarationSyntax)
        {
            return name;
        }

        return $"{GetFullyQualifiedName(node.Parent)}.{name}";
    }
}

// code from: https://stackoverflow.com/a/46095771/8304361
public static class StringExtensions
{
    private static Regex _invalidCharsRgx = new Regex("[^_a-zA-Z0-9]", RegexOptions.Compiled);
    private static Regex _whiteSpace = new Regex(@"(?<=\s)", RegexOptions.Compiled);
    private static Regex _startsWithLowerCaseChar = new Regex("^[a-z]", RegexOptions.Compiled);
    private static Regex _firstCharFollowedByUpperCasesOnly = new Regex("(?<=[A-Z])[A-Z0-9]+$", RegexOptions.Compiled);
    private static Regex _lowerCaseNextToNumber = new Regex("(?<=[0-9])[a-z]", RegexOptions.Compiled);
    private static Regex _upperCaseInside = new Regex("(?<=[A-Z])[A-Z]+?((?=[A-Z][a-z])|(?=[0-9]))", RegexOptions.Compiled);

    public static string ToPascalCase(this string s)
    {

        // replace white spaces with undescore, then replace all invalid chars with empty string
        var pascalCase = _invalidCharsRgx.Replace(_whiteSpace.Replace(s, "_"), string.Empty)
            // split by underscores
            .Split(new char[] { '_' }, StringSplitOptions.RemoveEmptyEntries)
            // set first letter to uppercase
            .Select(w => _startsWithLowerCaseChar.Replace(w, m => m.Value.ToUpper()))
            // replace second and all following upper case letters to lower if there is no next lower (ABC -> Abc)
            .Select(w => _firstCharFollowedByUpperCasesOnly.Replace(w, m => m.Value.ToLower()))
            // set upper case the first lower case following a number (Ab9cd -> Ab9Cd)
            .Select(w => _lowerCaseNextToNumber.Replace(w, m => m.Value.ToUpper()))
            // lower second and next upper case letters except the last if it follows by any lower (ABcDEf -> AbcDef)
            .Select(w => _upperCaseInside.Replace(w, m => m.Value.ToLower()));

        return string.Concat(pascalCase);
    }
}

Necessary packages:

  1. Microsoft.CodeAnalysis.CSharp
  2. Microsoft.CodeAnalysis.CSharp.Workspaces

You will see, that in the result code base.Channel.getDataAsync(request) is not renamed. I can guarantee that base.Channel is of type Test.web.


Solution

  • There was Metadata missing. That's why the semantic analysis was incomplete. In this specific use case of the questions minimal reproducible example there are classes of System.ServiceModel used but the type information is missing. That's why the type of the Channel, which is defined by the generic type parameter could not be resolved. So I added metadata references for all necessary DLLs to the created project:

    var project = new AdhocWorkspace().CurrentSolution.AddProject("TempProject", "TempAssembly", LanguageNames.CSharp)
                .AddMetadataReferences(GetAssemblyReferences());
    
    private static IEnumerable<MetadataReference> GetAssemblyReferences()
        {
            var outputDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
            var assembliesInOutput = Directory.GetFiles(outputDirectory, "*.dll");
            return assembliesInOutput
                .Select(assemblyPath => MetadataReference.CreateFromFile(assemblyPath))
                .ToList();
        }