Code Generation with Roslyn

From time to time, when I read about Roslyn and its analyzers, I constantly had the thought: "But you can make nuget with this thing, which will go around the code and do code generation." A quick search did not show anything interesting, so it was decided to dig. How pleasantly I was surprised when I discovered that my idea was not only feasible, but all this would work almost without crutches.


And so who is interested to look at how you can make a "small reflection" and pack it into nuget, please, under cat.


Introduction


I think the first thing to clarify is what is meant by "little reflection". I propose to implement a method for all types that will return the names of the properties and their types. Since this should work with all types, the easiest option would be to generate an extention method for each type. Its manual implementation will have the following form:Dictionary GetBakedType()


using System;
using System.Collections.Generic;
public static class testSimpleReflectionUserExtentions
{
    private static Dictionary properties = new Dictionary
        {
            { "Id", typeof(System.Guid)},
            { "FirstName", typeof(string)},
            { "LastName", typeof(string)},
        };
    public static Dictionary GetBakedType(this global::testSimpleReflection.User value)
    {
        return properties;
    }
}

There is nothing supernatural here, but to realize it for all types is a dreary and not interesting business which, moreover, threatens typos. Why don't we ask the compiler for help. This is where Roslyn and his analyzers enter the arena. They provide an opportunity to analyze the code and change it. So let's teach the compiler a new trick. Let him go around the code and see where we are using, but have not yet implemented ours GetBakedTypeand is implementing it.


To "enable" this functionality, we only need to install one nuget package and everything will work right away. Then we just call GetBakedTypewhere necessary, we get a compilation error that says that the reflection for this type is not ready yet, we call codefix and everything is ready. We have an extension method that will return all public properties to us.


I think in the movement it will be easier to understand how it generally works, here is a short visualization:



Anyone interested in trying this locally can install the nuget package under the name SimpleReflection:


Install-Package SimpleReflection

Who cares about the source, they are here .


I want to warn this implementation is not designed for real use. I just want to show a way to organize code generation with Roslyn.


Preliminary preparation


Before you start making your analyzers, you must install the component 'Visual Studio extention development' in the studio installer. For VS 2019, you must remember to select the ".NET Compiler Platform SDK" as an optional component.


Analyzer implementation


I will not describe in stages how to implement the analyzer, since it is very simple, but just go through the key points.


And the first key point will be that if we have a real compilation error, then the analyzers do not start at all. As a result, if we try to call ours GetBakedType()in the context of the type for which it is not implemented, we will get a compilation error and all our efforts will not make sense. But here we will be helped by the knowledge of the priority with which the compiler calls extension methods. The whole point is that specific implementations take precedence over generic methods. That is, in the following example, the second method will be called, not the first:


public static class SomeExtentions
{
    public static void Save(this T value)
    {
        ...
    }
    public static void Save(this User user)
    {
        ...
    }
}
public class Program 
{
    public static void Main(string[] args)
    {
        var user = new User();
        user.Save();
    }
}

This feature is very handy. We simply define universal GetBakedTypeas follows:


using System;
using System.Collections.Generic;
public static class StubExtention
{
    public static Dictionary GetBakedType(this TValue value)
    {
        return new Dictionary();
    }
}

This will allow us to avoid a compilation error at the very beginning and generate our own compilation "error".


Consider the analyzer itself. He will offer two diagnostics. The first is responsible for the case when code generation did not start at all, and the second when we need to update an existing code. They will have the following names SimpleReflectionIsNotReadyand SimpleReflectionUpdaterespectively. The first diagnostics will generate a "compilation error", and the second only reports that here you can run code generation again.


Description of diagnostics is as follows:


 public const string SimpleReflectionIsNotReady = "SimpleReflectionIsNotReady";
 public const string SimpleReflectionUpdate = "SimpleReflectionUpdate";
 public static DiagnosticDescriptor SimpleReflectionIsNotReadyDescriptor = new DiagnosticDescriptor(
             SimpleReflectionIsNotReady,
             "Simple reflection is not ready.",
             "Simple reflection is not ready.",
             "Codegen",
             DiagnosticSeverity.Error,
             isEnabledByDefault: true,
             "Simple reflection is not ready.");
 public static DiagnosticDescriptor SimpleReflectionUpdateDescriptor = new DiagnosticDescriptor(
         SimpleReflectionUpdate,
         "Simple reflection update.",
         "Simple reflection update.",
         "Codegen",
         DiagnosticSeverity.Info,
         isEnabledByDefault: true,
         "Simple reflection update.");

Next, it is necessary to determine what we will be looking for, in this case it will be a method call:


public override void Initialize(AnalysisContext context)
{
    context.RegisterOperationAction(this.HandelBuilder, OperationKind.Invocation);
}

Then there HandelBuilderis an analysis of the syntax tree. At the entrance we will receive all the calls that were found, so you need to weed out everything except ours GetBakedType. This can be done ifin the usual way in which we check the name of the method. Next we get the type of variable over which our method is called and inform the compiler about the results of our analysis. This may be a compilation error if the code generation has not yet started or the ability to restart it.


It all looks like this:


private void HandelBuilder(OperationAnalysisContext context)
{
    if (context.Operation.Syntax is InvocationExpressionSyntax invocation &&
        invocation.Expression is MemberAccessExpressionSyntax memberAccess &&
        memberAccess.Name is IdentifierNameSyntax methodName &&
        methodName.Identifier.ValueText == "GetBakedType")
    {
        var semanticModel = context.Compilation
            .GetSemanticModel(invocation.SyntaxTree);
        var typeInfo = semanticModel
            .GetSpeculativeTypeInfo(memberAccess.Expression.SpanStart, memberAccess.Expression, SpeculativeBindingOption.BindAsExpression);
        var diagnosticProperties = ImmutableDictionary.Empty.Add("type", typeInfo.Type.ToDisplayString());
        if (context.Compilation.GetTypeByMetadataName(typeInfo.Type.GetSimpleReflectionExtentionTypeName()) is INamedTypeSymbol extention)
        {
            var updateDiagnostic = Diagnostic.Create(SimpleReflectionUpdateDescriptor,
                methodName.GetLocation(),
                diagnosticProperties);
            context.ReportDiagnostic(updateDiagnostic);
            return;
        }
        var diagnostic = Diagnostic.Create(SimpleReflectionIsNotReadyDescriptor,
            methodName.GetLocation(),
            diagnosticProperties);
        context.ReportDiagnostic(diagnostic);
    }
}

Full analyzer code
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class SimpleReflectionAnalyzer : DiagnosticAnalyzer
    {
        public const string SimpleReflectionIsNotReady = "SimpleReflectionIsNotReady";
        public const string SimpleReflectionUpdate = "SimpleReflectionUpdate";
        public static DiagnosticDescriptor SimpleReflectionIsNotReadyDescriptor = new DiagnosticDescriptor(
                   SimpleReflectionIsNotReady,
                   "Simple reflection is not ready.",
                   "Simple reflection is not ready.",
                   "Codegen",
                   DiagnosticSeverity.Error,
                   isEnabledByDefault: true,
                   "Simple reflection is not ready.");
        public static DiagnosticDescriptor SimpleReflectionUpdateDescriptor = new DiagnosticDescriptor(
                SimpleReflectionUpdate,
                "Simple reflection update.",
                "Simple reflection update.",
                "Codegen",
                DiagnosticSeverity.Info,
                isEnabledByDefault: true,
                "Simple reflection update.");
        public override ImmutableArray SupportedDiagnostics
            => ImmutableArray.Create(SimpleReflectionIsNotReadyDescriptor, SimpleReflectionUpdateDescriptor);
        public override void Initialize(AnalysisContext context)
        {
            context.RegisterOperationAction(this.HandelBuilder, OperationKind.Invocation);
        }
        private void HandelBuilder(OperationAnalysisContext context)
        {
            if (context.Operation.Syntax is InvocationExpressionSyntax invocation &&
                invocation.Expression is MemberAccessExpressionSyntax memberAccess &&
                memberAccess.Name is IdentifierNameSyntax methodName &&
                methodName.Identifier.ValueText == "GetBakedType"
                )
            {
                var semanticModel = context.Compilation
                    .GetSemanticModel(invocation.SyntaxTree);
                var typeInfo = semanticModel
                    .GetSpeculativeTypeInfo(memberAccess.Expression.SpanStart, memberAccess.Expression, SpeculativeBindingOption.BindAsExpression);
                var diagnosticProperties = ImmutableDictionary.Empty.Add("type", typeInfo.Type.ToDisplayString());
                if (context.Compilation.GetTypeByMetadataName(typeInfo.Type.GetSimpleReflectionExtentionTypeName()) is INamedTypeSymbol extention)
                {
                    var updateDiagnostic = Diagnostic.Create(SimpleReflectionUpdateDescriptor,
                       methodName.GetLocation(),
                       diagnosticProperties);
                    context.ReportDiagnostic(updateDiagnostic);
                    return;
                }
                var diagnostic = Diagnostic.Create(SimpleReflectionIsNotReadyDescriptor,
                   methodName.GetLocation(),
                   diagnosticProperties);
                context.ReportDiagnostic(diagnostic);
            }
        }
    }

Code generator implementation


We will do code generation through CodeFixProvider, which is subscribed to our analyzer. First of all, we need to check what happened to find our analyzer.


It looks like this:


public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
    var diagnostic = context.Diagnostics.First();
    var title = diagnostic.Severity == DiagnosticSeverity.Error
        ? "Generate simple reflection"
        : "Recreate simple reflection";
    context.RegisterCodeFix(
        CodeAction.Create(
            title,
            createChangedDocument: token => this.CreateFormatterAsync(context, diagnostic, token),
            equivalenceKey: title),
        diagnostic);
}

All magic happens inside CreateFormatterAsync. In it we get a complete description of the type. Then we start code generation and add a new file to the project.


Getting information and adding a file:


 private async Task CreateFormatterAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken token)
{
    var typeName = diagnostic.Properties["type"];
    var currentDocument = context.Document;
    var model = await context.Document.GetSemanticModelAsync(token);
    var symbol = model.Compilation.GetTypeByMetadataName(typeName);
    var rawSource = this.BuildSimpleReflection(symbol);
    var source = Formatter.Format(SyntaxFactory.ParseSyntaxTree(rawSource).GetRoot(), new AdhocWorkspace()).ToFullString();
    var fileName = $"{symbol.GetSimpleReflectionExtentionTypeName()}.cs";
    if (context.Document.Project.Documents.FirstOrDefault(o => o.Name == fileName) is Document document)
    {
        return document.WithText(SourceText.From(source));
    }
    var folders = new[] { "SimpeReflection" };
    return currentDocument.Project
                .AddDocument(fileName, source)
                .WithFolders(folders);
}

Own code generation (I suspect that the hub will break the entire subnet):


private string BuildSimpleReflection(INamedTypeSymbol symbol) => $@"
using System;
using System.Collections.Generic;
// Simple reflection for {symbol.ToDisplayString()}
public static class {symbol.GetSimpleReflectionExtentionTypeName()}
{{
    private static Dictionary properties = new Dictionary
    {{
{ symbol
.GetAllMembers()
.OfType()
.Where(o => (o.DeclaredAccessibility & Accessibility.Public) > 0)
.Select(o => $@"            {{ ""{o.Name}"", typeof({o.Type.ToDisplayString()})}},")
.JoinWithNewLine() }
    }};
    public static Dictionary GetBakedType(this global::{symbol.ToDisplayString()} value)
    {{
        return properties;
    }}
}} ";
}

Complete code generator
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Text;
using SimpleReflection.Utils;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Composition;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace SimpleReflection
{
    [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SimpleReflectionCodeFixProvider)), Shared]
    public class SimpleReflectionCodeFixProvider : CodeFixProvider
    {
        public sealed override ImmutableArray FixableDiagnosticIds
            => ImmutableArray.Create(SimpleReflectionAnalyzer.SimpleReflectionIsNotReady, SimpleReflectionAnalyzer.SimpleReflectionUpdate);
        public sealed override FixAllProvider GetFixAllProvider()
        {
            return WellKnownFixAllProviders.BatchFixer;
        }
        public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
        {
            var diagnostic = context.Diagnostics.First();
            var title = diagnostic.Severity == DiagnosticSeverity.Error
                ? "Generate simple reflection"
                : "Recreate simple reflection";
            context.RegisterCodeFix(
                CodeAction.Create(
                    title,
                    createChangedDocument: token => this.CreateFormatterAsync(context, diagnostic, token),
                    equivalenceKey: title),
                diagnostic);
        }
        private async Task CreateFormatterAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken token)
        {
            var typeName = diagnostic.Properties["type"];
            var currentDocument = context.Document;
            var model = await context.Document.GetSemanticModelAsync(token);
            var symbol = model.Compilation.GetTypeByMetadataName(typeName);
            var symbolName = symbol.ToDisplayString();
            var rawSource = this.BuildSimpleReflection(symbol);
            var source = Formatter.Format(SyntaxFactory.ParseSyntaxTree(rawSource).GetRoot(), new AdhocWorkspace()).ToFullString();
            var fileName = $"{symbol.GetSimpleReflectionExtentionTypeName()}.cs";
            if (context.Document.Project.Documents.FirstOrDefault(o => o.Name == fileName) is Document document)
            {
                return document.WithText(SourceText.From(source));
            }
            var folders = new[] { "SimpeReflection" };
            return currentDocument.Project
                        .AddDocument(fileName, source)
                        .WithFolders(folders);
        }
        private string BuildSimpleReflection(INamedTypeSymbol symbol) => $@"
    using System;
    using System.Collections.Generic;
    // Simple reflection for {symbol.ToDisplayString()}
    public static class {symbol.GetSimpleReflectionExtentionTypeName()}
    {{
        private static Dictionary properties = new Dictionary
        {{
{ symbol
    .GetAllMembers()
    .OfType()
    .Where(o => (o.DeclaredAccessibility & Accessibility.Public) > 0)
    .Select(o => $@"            {{ ""{o.Name}"", typeof({o.Type.ToDisplayString()})}},")
    .JoinWithNewLine() }
        }};
        public static Dictionary GetBakedType(this global::{symbol.ToDisplayString()} value)
        {{
            return properties;
        }}
    }} ";
    }
}

Summary


As a result, we have a Roslyn analyzer-code generator with the help of which a "small" reflection using code generation is implemented. It will be difficult to come up with a real application for the current library, but it will be a great example for implementing easily accessible code generators. This approach can be, like any code generation, useful for writing serializers. My test implementation of MessagePack worked ~ 20% faster than neuecc / MessagePack-CSharp , and I have not seen a faster serializer yet. In addition, this approach does not require Roslyn.Emit, which is perfect for Unity and AOT scenarios.


Also popular now: