Search code examples
c#code-generationroslyn

How to completely evaluate an attribute's parameters in a C# source generator?


In a source generator, I've found an attribute on a class and resolved its FQN with GeneratorSyntaxContext.SemanticModel to, e.g., deal with its name being written with or without "Attribute" in it. How can I resolve the arguments? Basically I want to handle all of these:

// class MyAttribute : Attribute
// {
//   public MyAttribute(int first = 1, int second = 2, int third = 3) {...}
//   string Property {get;set;}
// }

[My]
[MyAttribute(1)]
[My(second: 8 + 1)]
[My(third: 9, first: 9)]
[My(1, second: 9)]
[My(Property = "Bl" + "ah")] // Extra, I can live without this but it would be nice

Most code I could find, including official samples, just hardcode ArgumentList[0], [1], etc. and the attribute's name written in "short form". Getting the attribute object itself or an identical copy would be ideal (it's not injected by the source generator but ProjectReferenced "normally" so the type is available) but it might be beyond Roslyn so just evaluating the constants and figuring out which value goes where is enough.


Solution

  • You can collect necessary information using syntax notifications. Here is a detailed walk-through.

    First, register the syntax receiver in your generator.

    [Generator]
    public sealed class MySourceGenerator : ISourceGenerator
    {
        public void Initialize(GeneratorInitializationContext context)
        {
            context.RegisterForSyntaxNotifications(() => new MySyntaxReceiver());
        }
    
        public void Execute(GeneratorExecutionContext context)
        {
            if (context.SyntaxReceiver is not MySyntaxReceiver receiver)
            {
                return;
            }
    
            foreach (var attributeDefinition in receiver.AttributeDefinitions)
            {
                var usage = attributeDefinition.ToSource();
                // 'usage' contains a string with ready-to-use attribute call syntax,
                // same as in the original code. For more details see AttributeDefinition.
    
                // ... some attributeDefinition usage here
            }
        }
    }
    

    MySyntaxReceiver does not do much. It waits for the AttributeSyntax instance, then creates and passes the AttributeCollector visitor to the Accept() method. Finally, it updates a list of the collected attribute definitions.

    internal class MySyntaxReceiver : ISyntaxReceiver
    {
        public List<AttributeDefinition> AttributeDefinitions { get; } = new();
    
        public void OnVisitSyntaxNode(SyntaxNode node)
        {
            if (node is AttributeSyntax attributeSyntax)
            {
                var collector = new AttributeCollector("My", "MyAttribute");
                attributeSyntax.Accept(collector);
                AttributeDefinitions.AddRange(collector.AttributeDefinitions);
            }
        }
    }
    

    All actual work happens in the AttributeCollector class. It uses a list of the AttributeDefinition records to store all found metadata. For an example of using this metadata, see the AttributeDefinition.ToSource() method.

    You can also evaluate the syntax.Expression property if needed. I didn't do it here.

    internal class AttributeCollector : CSharpSyntaxVisitor
    {
        private readonly HashSet<string> attributeNames;
    
        public List<AttributeDefinition> AttributeDefinitions { get; } = new();
    
        public AttributeCollector(params string[] attributeNames)
        {
            this.attributeNames = new HashSet<string>(attributeNames);
        }
    
        public override void VisitAttribute(AttributeSyntax node)
        {
            base.VisitAttribute(node);
    
            if (!attributeNames.Contains(node.Name.ToString()))
            {
                return;
            }
    
            var fieldArguments = new List<(string Name, object Value)>();
            var propertyArguments = new List<(string Name, object Value)>();
    
            var arguments = node.ArgumentList?.Arguments.ToArray() ?? Array.Empty<AttributeArgumentSyntax>();
            foreach (var syntax in arguments)
            {
                if (syntax.NameColon != null)
                {
                    fieldArguments.Add((syntax.NameColon.Name.ToString(), syntax.Expression));
                }
                else if (syntax.NameEquals != null)
                {
                    propertyArguments.Add((syntax.NameEquals.Name.ToString(), syntax.Expression));
                }
                else
                {
                    fieldArguments.Add((string.Empty, syntax.Expression));
                }
            }
    
            AttributeDefinitions.Add(new AttributeDefinition
            {
                Name = node.Name.ToString(),
                FieldArguments = fieldArguments.ToArray(),
                PropertyArguments = propertyArguments.ToArray()
            });
        }
    }
    
    internal record AttributeDefinition
    {
        public string Name { get; set; }
        public (string Name, object Value)[] FieldArguments { get; set; } = Array.Empty<(string Name, object Value)>();
        public (string Name, object Value)[] PropertyArguments { get; set; } = Array.Empty<(string Name, object Value)>();
    
        public string ToSource()
        {
            var definition = new StringBuilder(Name);
            if (!FieldArguments.Any() && !PropertyArguments.Any())
            {
                return definition.ToString();
            }
    
            return definition
                .Append("(")
                .Append(ArgumentsToString())
                .Append(")")
                .ToString();
        }
    
        private string ArgumentsToString()
        {
            var arguments = new StringBuilder();
    
            if (FieldArguments.Any())
            {
                arguments.Append(string.Join(", ", FieldArguments.Select(
                    param => string.IsNullOrEmpty(param.Name)
                        ? $"{param.Value}"
                        : $"{param.Name}: {param.Value}")
                ));
            }
    
            if (PropertyArguments.Any())
            {
                arguments
                    .Append(arguments.Length > 0 ? ", " : "")
                    .Append(string.Join(", ", PropertyArguments.Select(
                        param => $"{param.Name} = {param.Value}")
                    ));
            }
    
            return arguments.ToString();
        }
    }