JesseTG
JesseTG

Reputation: 2123

How to use Roslyn generate an array of string literals that are each wrapped in an #if?

The Use Case

It would be helpful to know at runtime which #define symbols were used to build a C# application. Since it doesn't appear to be possible to actually do that, I'm instead using Roslyn to generate an enormous string[] with every #define I might possibly be interested in listing. Each #define should be stored in this array as a string literal which itself is only included if the associated symbol is #defined, like so:

// This class is generated, do not change it manually.

public static class DefineSymbols
{
    public static System.Collections.Generic.IReadOnlyList<string> Symbols => _symbols;

    private static readonly string[] _symbols = new string[] {
#if DEBUG
        "DEBUG",
#endif
#if UNITY
        "UNITY",
#endif
#if UNITY_ASSERTIONS
        "UNITY_ASSERTIONS",
#endif
        // ...and lots more symbols...
    };
}

The Problem

The easy part is generating a big-ass array of string literals. The hard part is placing the commas properly. Using this code...

private SyntaxNode CreateSymbolsArray(SyntaxGenerator generator, string[] defines)
{
    var stringType = generator.TypeExpression(SpecialType.System_String);

    return generator.FieldDeclaration(
        name: "_symbols",
        type: generator.ArrayTypeExpression(stringType),
        accessibility: Accessibility.Private,
        modifiers: DeclarationModifiers.Static | DeclarationModifiers.ReadOnly,
        initializer: generator.ArrayCreationExpression(
            generator.TypeExpression(SpecialType.System_String),
            defines.Select(d => CreateDefine(generator, d))
        )
    );
}


private SyntaxNode CreateDefine(SyntaxGenerator generator, string define)
{
    var @if = SyntaxFactory.IfDirectiveTrivia(SyntaxFactory.ParseExpression(define), true, true, true);
    var @endif = SyntaxFactory.EndIfDirectiveTrivia(true);

    return generator.LiteralExpression(define)
            .WithLeadingTrivia(SyntaxFactory.Trivia(@if))
            .WithTrailingTrivia(SyntaxFactory.Trivia(@endif))
        ;
}

...places commas outside of the #if/#endif pairs, so the generated code won't compile unless every tested symbol is defined.

    private static readonly string[] _symbols = new string[] {
#if DEBUG
        "DEBUG"
#endif
    ,
#if UNITY
        "UNITY"
#endif
    ,
#if UNITY_ASSERTIONS
        "UNITY_ASSERTIONS"
#endif
        // ...and lots more symbols...
    };

So, here's my question: How can I ensure the commas are wrapped inside the #if/#endif pairs just like the string literals are?

Upvotes: 1

Views: 797

Answers (1)

JesseTG
JesseTG

Reputation: 2123

With the help of Roslyn Quoter, I figured it out! Roslyn Quoter is a tool that lets you enter a C# snippet and see what API calls you'd need to generate it.

Providing Roslyn Quoter my original code sample returns a snippet that's too long to post verbatim. It was useful in helping me write something simpler and more concise, however.

TLDR: To solve my exact problem, you need to provide everything as a sequence of SyntaxTrivia nodes that leads a closing-brace token (}).


Here are some more details. I created three SyntaxTrivia nodes for each #define symbol:

  1. The #if directive
  2. The string literal, as a piece of disabled text (which is fine, because the disabled text itself is simple)
  3. The #endif directive

Here's what that looks like:

using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

// ...later, within the actual class...

private SyntaxTrivia[] CreateDefine(string define)
{
    return new[]
    {
        Trivia(
            IfDirectiveTrivia(
                IdentifierName(define),
                true,
                false,
                false
            )
        ), // # if MY_DEFINE
        DisabledText($"        \"{define}\",\n"),  // "MY_DEFINE",
        Trivia(EndIfDirectiveTrivia(true)),  // #endif
    };
}

/* Output looks like this:
#if MY_DEFINE
        "MY_DEFINE",
#endif
*/

After that, the tricky part is the array initialization expression. Here's how I did that.

The tricky part after that is putting everything in an initializer expression, which in practice looks like an empty array initializer with a lot of leading trivia. Here's how I did that:

using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

// ...later, within the actual class...

private SyntaxNode CreateDefinesArrayInitializerExpression(string[] defines)
{
    var stringType = PredefinedType(Token(SyntaxKind.StringKeyword)); // string
    var arrayType = ArrayType(stringType)
        .WithRankSpecifiers(
            SingletonList(
                ArrayRankSpecifier(
                    SingletonSeparatedList<ExpressionSyntax>(
                        OmittedArraySizeExpression()
                    )
                ) // []
            )
        )
    ; // string[]

    return ArrayCreationExpression(arrayType) // new
        .WithInitializer(
            InitializerExpression(SyntaxKind.ArrayInitializerExpression) // string[] {
                .WithCloseBraceToken(
                    Token(
                        TriviaList(defines.SelectMany(CreateDefine)), // <all the #defines>
                        SyntaxKind.CloseBraceToken, // }
                        TriviaList() // <nothing>
                    )
                 )
        )
        .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) // ;
    ;
}

/* Output looks like this:
new string[] {
#if DEBUG
        "DEBUG"
#endif
    ,
#if UNITY
        "UNITY"
#endif
    ,
#if UNITY_ASSERTIONS
        "UNITY_ASSERTIONS"
#endif
    };
*/

It's verbose, but you can write some extension methods to help; I did, but I omitted them to simplify copy-pasting. There might even be a NuGet package full of them floating around somewhere.

Upvotes: 1

Related Questions