Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Create Roslyn C# analyzer that is aware of constructor argument types for class in assembly

Background:

I have an attribute that indicates that a property of field in an object IsMagic. I also have a Magician class that runs over any object and MakesMagic by extracting each field and property that IsMagic and wraps it in a Magic wrapper.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

namespace MagicTest
{

    /// <summary>
    /// An attribute that allows us to decorate a class with information that identifies which member is magic.
    /// </summary>
    [AttributeUsage(AttributeTargets.Property|AttributeTargets.Field, AllowMultiple = false)]
    class IsMagic : Attribute { }

    public class Magic
    {
        // Internal data storage
        readonly public dynamic value;

        #region My ever-growing list of constructors
        public Magic(int input) { value = input; }
        public Magic(string input) { value = input; }
        public Magic(IEnumerable<bool> input) { value = input; }
        // ...
        #endregion

        public bool CanMakeMagicFromType(Type targetType)
        {
            if (targetType == null) return false;
            ConstructorInfo publicConstructor = typeof(Magic).GetConstructor(new[] { targetType });
            if (publicConstructor != null) return true;  // We can make Magic from this input type!!!
            return false;
        }

        public override string ToString()
        {
            return value.ToString(); 
        }
    }

    public static class Magician
    {
        /// <summary>
        /// A method that returns the members of anObject that have been marked with an IsMagic attribute.
        /// Each member will be wrapped in Magic.
        /// </summary>
        /// <param name="anObject"></param>
        /// <returns></returns>
        public static List<Magic> MakeMagic(object anObject)
        {
            Type type = anObject?.GetType() ?? null;
            if (type == null) return null; // Sanity check

            List<Magic> returnList = new List<Magic>();

            // Any field or property of the class that IsMagic gets added to the returnList in a Magic wrapper
            MemberInfo[] objectMembers = type.GetMembers();
            foreach (MemberInfo mi in objectMembers)
            {
                bool isMagic = (mi.GetCustomAttributes<IsMagic>().Count() > 0);
                if (isMagic)
                {
                    dynamic memberValue = null;
                    if (mi.MemberType == MemberTypes.Property) memberValue = ((PropertyInfo)mi).GetValue(anObject);
                    else if (mi.MemberType == MemberTypes.Field) memberValue = ((FieldInfo)mi).GetValue(anObject);
                    if (memberValue == null) continue;

                    returnList.Add(new Magic(memberValue)); // This could fail at run-time!!!
                }

            }

            return returnList;
        }
    }
}

The Magician can MakeMagic on anObject with at least one field or property that IsMagic to produce a generic List of Magic, like so:

using System;
using System.Collections.Generic;

namespace MagicTest
{
    class Program
    {
        class Mundane
        {
            [IsMagic] public string foo;
            [IsMagic] public int feep;
            public float zorp; // If this [IsMagic], we'll have a run-time error
        }

        static void Main(string[] args)
        {
            Mundane anObject = new Mundane
            {
                foo = "this is foo",
                feep = -10,
                zorp = 1.3f
            };

            Console.WriteLine("Magic:");
            List<Magic> myMagics = Magician.MakeMagic(anObject);
            foreach (Magic aMagic in myMagics) Console.WriteLine("  {0}",aMagic.ToString());
            Console.WriteLine("More Magic: {0}", new Magic("this works!"));
            //Console.WriteLine("More Magic: {0}", new Magic(Mundane)); // build-time error!

            Console.WriteLine("\nPress Enter to continue");
            Console.ReadLine();
        }
    }
}

Notice that Magic wrappers can only go around properties or fields of certain types. This means that only property or field that contains data of specific types should be marked as IsMagic. To make matters more complicated, I expect the list of specific types to change as business needs evolve (since programming Magic is in such high demand).

The good news is that the Magic has some build time safety. If I try to add code like new Magic(true) Visual Studio will tell me it's wrong, since there is no constructor for Magic that takes a bool. There is also some run-time checking, since the Magic.CanMakeMagicFromType method can be used to catch problems with dynamic variables.

Problem Description:

The bad news is that there's no build-time checking on the IsMagic attribute. I can happily say a Dictionary<string,bool> field in some class IsMagic, and I won't be told that it's a problem until run-time. Even worse, the users of my magical code will be creating their own mundane classes and decorating their properties and fields with the IsMagic attribute. I'd like to help them see problems before they become problems.

Proposed Solution:

Ideally, I could put some kind of AttributeUsage flag on my IsMagic attribute to tell Visual Studio to use the Magic.CanMakeMagicFromType() method to check the property or field type that the IsMagic attribute is being attached to. Unfortunately, there doesn't seem to be such an attribute.

However, it seems like it should be possible to use Roslyn to present an error when IsMagic is placed on a field or property that has a Type that can't be wrapped in Magic.

Where I need help:

I am having trouble designing the Roslyn analyser. The heart of the problem is that Magic.CanMakeMagicFromType takes in System.Type, but Roslyn uses ITypeSymbol to represent object types.

The ideal analyzer would:

  1. Not require me to keep a list of allowed types that can be wrapped in Magic. After all, Magic has a list of constructors that serve this purpose.
  2. Allow natural casting of types. For instance, if Magic has a constructor that takes in IEnumerable<bool>, then Roslyn should allow IsMagic to be attached to a property with type List<bool> or bool[]. This casting of Magic is critical to the Magician's functionality.

I'd appreciate any direction on how to code a Roslyn analyzer that is "aware" of the constructors in Magic.

like image 717
Neils Schoenfelder Avatar asked Oct 18 '18 19:10

Neils Schoenfelder


Video Answer


2 Answers

You need to rewrite CanMakeMagicFromType() using Roslyn's semantic model APIs and ITypeSymbol.

Start by calling Compilation.GetTypeByMetadataName() to get the INamedTypeSymbol for Magic. You can then enumerate its constructors & parameters and call .ClassifyConversion to see whether they're compatible with the property type.

like image 162
SLaks Avatar answered Sep 27 '22 21:09

SLaks


Based on the excellent advice from SLaks, I was able to code up a complete solution.

The code analyzer that spots mis-applied attributes looks like this:

using System;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace AttributeAnalyzer
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class AttributeAnalyzerAnalyzer : DiagnosticAnalyzer
    {
        public const string DiagnosticId = "AttributeAnalyzer";

        private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
                id: DiagnosticId,
                title: "Magic cannot be constructed from Type",
                messageFormat: "Magic cannot be built from Type '{0}'.",
                category: "Design",
                defaultSeverity: DiagnosticSeverity.Error,
                isEnabledByDefault: true,
                description: "The IsMagic attribue needs to be attached to Types that can be rendered as Magic."
                );
        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

        public override void Initialize(AnalysisContext context)
        {
            context.RegisterSyntaxNodeAction(
                AnalyzeSyntax,
                SyntaxKind.PropertyDeclaration, SyntaxKind.FieldDeclaration
                );
        }

        private static void AnalyzeSyntax(SyntaxNodeAnalysisContext context)
        {
            ITypeSymbol memberTypeSymbol = null;
            if (context.ContainingSymbol is IPropertySymbol)
            {
                memberTypeSymbol = (context.ContainingSymbol as IPropertySymbol)?.GetMethod?.ReturnType;
            }
            else if (context.ContainingSymbol is IFieldSymbol)
            {
                memberTypeSymbol = (context.ContainingSymbol as IFieldSymbol)?.Type;
            }
            else throw new InvalidOperationException("Can only analyze property and field declarations.");

            // Check if this property of field is decorated with the IsMagic attribute
            INamedTypeSymbol isMagicAttribute = context.SemanticModel.Compilation.GetTypeByMetadataName("MagicTest.IsMagic");
            ISymbol thisSymbol = context.ContainingSymbol;
            ImmutableArray<AttributeData> attributes = thisSymbol.GetAttributes();
            bool hasMagic = false;
            Location attributeLocation = null;
            foreach (AttributeData attribute in attributes)
            {
                if (attribute.AttributeClass != isMagicAttribute) continue;
                hasMagic = true;
                attributeLocation = attribute.ApplicationSyntaxReference.SyntaxTree.GetLocation(attribute.ApplicationSyntaxReference.Span);
                break;
            }
            if (!hasMagic) return;

            // Check if we can make Magic using the current property or field type
            if (!CanMakeMagic(context,memberTypeSymbol))
            {
                var diagnostic = Diagnostic.Create(Rule, attributeLocation, memberTypeSymbol.Name);
                context.ReportDiagnostic(diagnostic);
            }

        }

        /// <summary>
        /// Check if a given type can be wrapped in Magic in the current context.
        /// </summary>
        /// <param name="context"></param>
        /// <param name="sourceTypeSymbol"></param>
        /// <returns></returns>
        private static bool CanMakeMagic(SyntaxNodeAnalysisContext context, ITypeSymbol sourceTypeSymbol)
        {
            INamedTypeSymbol magic = context.SemanticModel.Compilation.GetTypeByMetadataName("MagicTest.Magic");
            ImmutableArray<IMethodSymbol> constructors = magic.Constructors;

            foreach (IMethodSymbol methodSymbol in constructors)
            {
                ImmutableArray<IParameterSymbol> parameters = methodSymbol.Parameters;
                IParameterSymbol param = parameters[0]; // All Magic constructors take one parameter
                ITypeSymbol paramType = param.Type;

                Conversion conversion = context.Compilation.ClassifyConversion(sourceTypeSymbol, paramType);
                if (conversion.Exists && conversion.IsImplicit) return true; // We've found at least one way to make Magic
            }

            return false;
        }
    }
}

The CanMakeMagic function has the magic solution that SLaks spelled out for me.

The code fix provider looks like this:

using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace AttributeAnalyzer
{
    [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AttributeAnalyzerCodeFixProvider)), Shared]
    public class AttributeAnalyzerCodeFixProvider : CodeFixProvider
    {
        public sealed override ImmutableArray<string> FixableDiagnosticIds
        {
            get { return ImmutableArray.Create(AttributeAnalyzerAnalyzer.DiagnosticId); }
        }

        public sealed override FixAllProvider GetFixAllProvider()
        {
            // See https://github.com/dotnet/roslyn/blob/master/docs/analyzers/FixAllProvider.md for more information on Fix All Providers
            return WellKnownFixAllProviders.BatchFixer;
        }

        public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
        {
            Diagnostic diagnostic = context.Diagnostics.First();
            TextSpan diagnosticSpan = diagnostic.Location.SourceSpan;

            context.RegisterCodeFix(
                CodeAction.Create(
                    title: "Remove attribute",
                    createChangedDocument: c => RemoveAttributeAsync(context.Document, diagnosticSpan, context.CancellationToken),
                    equivalenceKey: "Remove_Attribute"
                    ),
                diagnostic
                );            
        }

        private async Task<Document> RemoveAttributeAsync(Document document, TextSpan diagnosticSpan, CancellationToken cancellation)
        {
            SyntaxNode root = await document.GetSyntaxRootAsync(cancellation).ConfigureAwait(false);
            AttributeListSyntax attributeListDeclaration = root.FindNode(diagnosticSpan).FirstAncestorOrSelf<AttributeListSyntax>();
            SeparatedSyntaxList<AttributeSyntax> attributes = attributeListDeclaration.Attributes;

            if (attributes.Count > 1)
            {
                AttributeSyntax targetAttribute = root.FindNode(diagnosticSpan).FirstAncestorOrSelf<AttributeSyntax>();
                return document.WithSyntaxRoot(
                    root.RemoveNode(targetAttribute,
                    SyntaxRemoveOptions.KeepExteriorTrivia | SyntaxRemoveOptions.KeepEndOfLine | SyntaxRemoveOptions.KeepDirectives)
                    );
            }
            if (attributes.Count==1)
            {
                return document.WithSyntaxRoot(
                    root.RemoveNode(attributeListDeclaration,
                    SyntaxRemoveOptions.KeepExteriorTrivia | SyntaxRemoveOptions.KeepEndOfLine | SyntaxRemoveOptions.KeepDirectives)
                    );
            }
            return document;
        }
    }
}

The only cleverness required here is sometimes removing a single attribute, and other times removing an entire attribute list.

I'm marking this as the accepted answer; but, in the interest of full disclosure, I would never have figured this out without SLaks help.

like image 38
Neils Schoenfelder Avatar answered Sep 27 '22 22:09

Neils Schoenfelder