LoginSignup
4
3

dotnetコマンドでUnity用SourceGeneratorを自作する

Last updated at Posted at 2024-03-17

概要

UnityでSourceGeneratorが話題になってるものの、公式サイト等では Visual Studio での作成方法ばかりだったのでmacやLinuxで作るべくdotnetコマンドを使って実装してみました。
(つまり公式の手順のうちDLLのプロジェクト作成とビルドをdotnetコマンドでやっただけ)
その時の手順メモです。

さっと知りたい人向けに公式サイトとの違い

以下の手順を置き換えるだけでした。

Visual Studioで、.NET Standard 2.0 をターゲットとする .NET 標準ライブラリプロジェクトを作成します。

dotnet new classlib -lang "C#" -f "netstandard2.0" -o "MyGenerateAttribute"

Microsoft.CodeAnalysis NuGet パッケージをインストールします。

dotnet add package "Microsoft.CodeAnalysis.CSharp" --version "3.8.0"

作成方法(フルバージョン)

# プロジェクト新規作成
dotnet new classlib -lang "C#" -f "netstandard2.0" -o "MyGenerateAttribute"
# 必要ならソリューションを作成
dotnet new sln
dotnet sln add MyGenerateAttribute/MyGenerateAttribute.csproj
# 必要なパッケージをインストール
cd MyGenerateAttribute
dotnet add package "Microsoft.CodeAnalysis.CSharp" --version "3.8.0"
# コード書いたらビルド
dotnet build -c Release
cp bin/Release/netstandard2.0/*.dll /path/to/UnityProject/Assets/Plugins

上記で配置し終わったらUnityで配置したDLLのインスペクタを開いて

  • Select platforms for pluginからAny Platformのチェックを外す
  • Include Platformsから全てのチェックを外す
  • Asset LabelsRoslynAnalyzerに設定する

とすれば準備は完了です。
あとはUnityで通常通りコードを書くだけです。

今回作成したコード

とりあえず目標としてはフィールドに設定することでGetプロパティを生成するSourceGeneratorを作りたいと思います。
理由は

  • 既存コードからテンプレート的にコードを生成するための最低限の解析手順が踏めそう
  • 生成自体も分岐が少なく簡単に出力できそう

という点で、公式サンプルから解析部分を読み取ってその結果を使って何かする、というのにちょうど良さそうな大きさだったからです。

名前とか、細かい部分は適当なので「このパターンのコードだとエラーになる」とかあるので適宜修正してください...

SourceGenerator のコード

using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace MyLib
{
    [Generator]
    public class MyGenerator : ISourceGenerator
    {
        // Attributeのコード
        private const string ATTRIBUTE_CODE =@"
        namespace MyGenerateAttributeNS {
            /// <summary>
            /// フィールドのGetプロパティを生成する
            /// </summary>
            [System.AttributeUsage(System.AttributeTargets.Field, AllowMultiple = false)]
            internal sealed class MySourceGeneratorAttribute : System.Attribute
            {
                /// <summary>
                /// プレフィックス
                /// </summary>
                public string Prefix { get; set; }
                /// <summary>
                /// フィールドのGetプロパティ生成
                /// </summary>
                public MySourceGeneratorAttribute() {
                }
            }
        }";

        public void Initialize(GeneratorInitializationContext context)
        {
            context.RegisterForSyntaxNotifications(() => new MySyntaxReceiver());
        }

        public void Execute(GeneratorExecutionContext context)
        {
            // Attribute のソースコードを追加
            context.AddSource("MySourceGeneratorAttribute.g.cs", ATTRIBUTE_CODE);
            if (!(context.SyntaxReceiver is MySyntaxReceiver syntaxReceiver))
                return;

            // Attribute のソースコードを解析
            CSharpParseOptions options = (context.Compilation as CSharpCompilation)
                .SyntaxTrees
                .First()
                .Options as CSharpParseOptions;
            var compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(ATTRIBUTE_CODE, encoding: Encoding.UTF8), options));
            // SourceGenerator のAttributeが付与されたシンボルを取り出す
            var attributeSymbol = compilation.GetTypeByMetadataName("MyGenerateAttributeNS.MySourceGeneratorAttribute");
            var fieldSymbols = new List<IFieldSymbol>();
            
            foreach (var field in syntaxReceiver.CandidateFields)
            {
                var model = compilation.GetSemanticModel(field.SyntaxTree);
                foreach (var variable in field.Declaration.Variables)
                {
                    var fieldSymbol = model.GetDeclaredSymbol(variable) as IFieldSymbol;
                    if (fieldSymbol.GetAttributes().Any(att => att.AttributeClass.Equals(attributeSymbol, SymbolEqualityComparer.Default)))
                    {
                        fieldSymbols.Add(fieldSymbol);
                    }
                }
            }
            // 実際にAttributeが付与されたソースコードから新しいソースコードを作成する
            foreach (var group in fieldSymbols.GroupBy(f => f.ContainingType))
            {
                var code = MakeGetPropertyCode(group.Key, group.ToArray(), attributeSymbol);
                context.AddSource($"{group.Key}__MySourceGeneratorAttribute.g.cs", code);
            }
        }

        private string MakeGetPropertyCode(INamedTypeSymbol classSymbol, IFieldSymbol[] fieldSymbols, ISymbol attributeSymbol)
        {
            if (!classSymbol.ContainingSymbol.Equals(classSymbol.ContainingNamespace, SymbolEqualityComparer.Default))
            {
                return null;
            }
            var namespaceString = classSymbol.ContainingNamespace.ToDisplayString();
            var codeBuilder = new StringBuilder();
            codeBuilder.AppendLine($"namespace {namespaceString} {{");
            codeBuilder.AppendLine($"public partial class {classSymbol.Name} {{");
            foreach (var fieldSymbol in fieldSymbols)
            {
                var fieldName = fieldSymbol.Name;
                var fieldType = fieldSymbol.Type;
                var attributeType = fieldSymbol.GetAttributes().Single(ad => ad.AttributeClass.Equals(attributeSymbol, SymbolEqualityComparer.Default));
                var prefixConstruct = attributeType.NamedArguments.Single(keyValue => keyValue.Key == "Prefix").Value;
                if (prefixConstruct.IsNull)
                {
                    continue;
                }
                var prefix = prefixConstruct.Value?.ToString();
                codeBuilder.AppendLine($"public {fieldType.ToDisplayString()} {prefix}{fieldName.Substring(0, 1).ToUpper()}{fieldName.Substring(1)} => {fieldName};");
            }
            codeBuilder.AppendLine("}");
            codeBuilder.AppendLine("}");
            return codeBuilder.ToString();
        }

        internal class MySyntaxReceiver : ISyntaxReceiver
        {
            public List<FieldDeclarationSyntax> CandidateFields = new List<FieldDeclarationSyntax>();
            public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
            {
                if (!(syntaxNode is FieldDeclarationSyntax fieldDecSyntax))
                    return;
                if (fieldDecSyntax.AttributeLists.Count <= 0)
                    return;
                this.CandidateFields.Add(fieldDecSyntax);
            }
        }
    }
}

Unity側のコード

namespace Data {
public partial class UserData
{
    [MyGenerateAttributeNS.MySourceGenerator(Prefix = "")]
    private int id;
    [MyGenerateAttributeNS.MySourceGenerator(Prefix = "User")]
    private string name;

    public UserData(int id, string name) {
        this.id = id;
        this.name = name;
    }
}
}
using UnityEngine;

public class MainScene : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        var userData = new Data.UserData(1, "Taro");

        Debug.Log($"Generated Property: {userData.Id} {userData.UserName}");
    }
}

作成のポイント

Unityドキュメントにも書いてありますがバージョン指定などがされているのでコマンド実行時に指定を忘れないようにするのがポイントです。

  • .NET のバージョン
    • .NET Standard 2.0
      dotnet new classlib -lang "C#" -f "netstandard2.0"
  • Microsoft.CodeAnalysis のバージョン
    • Microsoft.CodeAnalysis 3.8
      dotnet add package "Microsoft.CodeAnalysis.CSharp" --version "3.8.0"

また、これに伴ってMicrosoftのSourceGeneratorサンプル16.7のtagを参照するのが良さそうです。
mainブランチの最新だといくつかMicrosoft.CodeAnalysis.CSharp 3.8ではまだ使えないメソッドがありました。

参考

Unity マニュアルのページ:
https://docs.unity3d.com/ja/2022.3/Manual/roslyn-analyzers.html
Microsoft の SourceGenerator サンプル:
https://github.com/dotnet/roslyn-sdk/tree/16.7/samples/CSharp/SourceGenerators/SourceGeneratorSamples

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3