しっぽを追いかけて

ぐるぐるしながら考えています

Unity と猫の話題が中心   掲載内容は個人の私見であり、所属組織の見解ではありません

Unity でソースコードの生成をやりやすくしたい

※ これは 2023/10/05 時点の Unity 2023.1.16f1 の情報です

最新版では動作が異なる可能性がありますのでご注意ください

前回で Unity の Roslyn を属性定義を参照したコード生成をしてみたが、インデントなどの吐き出しが面倒なのでかんたんにできるようにしたい

というわけで下記のようなクラスを追加した

using System;
using System.Text;

namespace SourceGenerator
{
    /// <summary>
    /// ソースコード生成クラス
    /// </summary>
    public class CodeBuilder
    {
        private readonly StringBuilder builder = new StringBuilder();
        private int indentLevel = 0;
        private string indent = string.Empty;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="isGenerated">自動生成フラグ</param>
        public CodeBuilder(bool isGenerated = false)
        {
            if (isGenerated)
            {
                this.AppendLine("// <auto-generated/>");
            }
        }

        /// <summary>
        /// コード文追加
        /// </summary>
        /// <param name="value">コード</param>
        public void Append(string value)
        {
            this.builder.Append(this.indent);
            this.builder.Append(value);
        }

        /// <summary>
        /// 行追加
        /// </summary>
        /// <param name="value">1行分のコード</param>
        public void AppendLine(string value = null)
        {
            if (value != null)
            {
                this.Append(value);
            }
            this.builder.AppendLine();
        }

        /// <summary>
        /// インデントレベル変更
        /// </summary>
        /// <param name="width">追加するレベル</param>
        public void ShiftIndent(int width)
        {
            this.indentLevel = Math.Max(0, this.indentLevel + width);
            this.indent = new string('\t', this.indentLevel);
        }

        /// <summary>
        /// summary コメントの追加
        /// </summary>
        /// <param name="summary"></param>
        public void AppendSummary(string summary)
        {
            this.AppendLine("/// <summary>");
            this.AppendLine($"/// {summary}");
            this.AppendLine("/// </summary>");
        }

        /// <summary>
        /// inheritdoc コメントの追加
        /// </summary>
        public void AppendInheritcoc()
        {
            this.AppendLine("/// <inheritdoc/>");
        }

        /// <summary>
        /// ブロックの開始
        /// </summary>
        /// <param name="code">先頭行コード</param>
        public CodeBlock BeginBlock(string code = null)
        {
            if (code != null)
            {
                this.AppendLine(code);
            }
            this.AppendLine("{");
            this.ShiftIndent(1);
            return new CodeBlock(this);
        }

        /// <summary>
        /// ブロックの終了
        /// </summary>
        public void EndBlock()
        {
            this.ShiftIndent(-1);
            this.AppendLine("}");
        }

        /// <inheritdoc/>
        public override string ToString()
        {
            return this.builder.ToString();
        }

        /// <summary>
        /// 生成コードの破棄
        /// </summary>
        public void Clear()
        {
            this.builder.Clear();
            this.indentLevel = 0;
            this.indent = string.Empty;
        }
    }

    /// <summary>
    /// コードブロッククラス
    /// </summary>
    public class CodeBlock : IDisposable
    {
        private CodeBuilder builder;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="builder">コード生成クラス</param>
        public CodeBlock(CodeBuilder builder)
        {
            this.builder = builder;
        }

        /// <inheritdoc/>
        public void Dispose()
        {
            this.builder.EndBlock();
            this.builder = null;
        }
    }
}

この CodeBuilder を使ってコード生成分を書き換えてみる

変更前のこの記述が

        private void CreateProperties(GeneratorExecutionContext context, SyntaxReceiver receiver, Compilation compilation)
        {
            foreach (var classSyntax in receiver.TargetClasses)
            {
                var model = compilation.GetSemanticModel(classSyntax.SyntaxTree);
                var symbol = model.GetDeclaredSymbol(classSyntax);
                var name = symbol.Name;
                var properties = symbol.GetAttributes()
                    .Where(x => x.AttributeClass.Name == "SimplePropertyAttribute")
                    .Select(x => (Name: x.ConstructorArguments[0].Value.ToString(), Comment: x.ConstructorArguments[1].Value.ToString()))
                    .ToArray();

                var sb = new StringBuilder();
                sb.AppendLine("// <auto-generated/>");
                sb.AppendLine("using System.Collections.Generic;");
                sb.AppendLine();
                sb.AppendLine($"public partial class {name}");
                sb.Append("{");
                foreach (var property in properties)
                {
                    var intent = "\t";
                    var publicName = char.ToUpper(property.Name[0]) + property.Name.Substring(1);
                    var localName = char.ToLower(property.Name[0]) + property.Name.Substring(1);

                    sb.AppendLine("");
                    sb.AppendLine(intent + "/// <summary>");
                    sb.AppendLine(intent + $"/// {property.Comment}");
                    sb.AppendLine(intent + "/// </summary>");
                    sb.AppendLine(intent + $"public string {publicName}");
                    sb.AppendLine(intent + "{");

                    {
                        intent += "\t";

                        sb.AppendLine(intent + $"get => this.{localName};");
                        sb.AppendLine(intent + $"set");
                        sb.AppendLine(intent + "{");
                        {
                            intent += "\t";

                            sb.AppendLine(intent + $"if (this.{localName} != value)");
                            sb.AppendLine(intent + "{");
                            {
                                intent += "\t";

                                sb.AppendLine(intent + $"this.{localName} = value;");

                                intent = intent.Substring(0, intent.Length - 1);
                            }
                            sb.AppendLine(intent + "}");

                            intent = intent.Substring(0, intent.Length - 1);
                        }
                        sb.AppendLine(intent + "}");

                        intent = intent.Substring(0, intent.Length - 1);
                    }
                    sb.AppendLine(intent + "}");
                    sb.AppendLine(intent + $"private string {localName} = null;");
                }
                sb.AppendLine("}");

                context.AddSource($"{name}.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
            }
        }

こうなった

        private void CreateProperties(GeneratorExecutionContext context, SyntaxReceiver receiver, Compilation compilation)
        {
            foreach (var classSyntax in receiver.TargetClasses)
            {
                var model = compilation.GetSemanticModel(classSyntax.SyntaxTree);
                var symbol = model.GetDeclaredSymbol(classSyntax);
                var name = symbol.Name;
                var properties = symbol.GetAttributes()
                    .Where(x => x.AttributeClass.Name == "SimplePropertyAttribute")
                    .Select(x => (Name: x.ConstructorArguments[0].Value.ToString(), Comment: x.ConstructorArguments[1].Value.ToString()))
                    .ToArray();

                var sb = new CodeBuilder(true);
                sb.AppendLine("using System.Collections.Generic;");
                sb.AppendLine();
                using (sb.BeginBlock($"public partial class {name}"))
                {
                    foreach (var property in properties)
                    {
                        var publicName = char.ToUpper(property.Name[0]) + property.Name.Substring(1);
                        var localName = char.ToLower(property.Name[0]) + property.Name.Substring(1);

                        sb.AppendLine();
                        sb.AppendSummary(property.Comment);
                        using (sb.BeginBlock($"public string {publicName}"))
                        {
                            sb.AppendLine($"get => this.{localName};");
                            using (sb.BeginBlock("set"))
                            {
                                using (sb.BeginBlock($"if (this.{localName} != value)"))
                                {
                                    sb.AppendLine($"this.{localName} = value;");
                                }
                            }
                        }
                        sb.AppendLine($"private string {localName} = null;");
                    }
                }

                context.AddSource($"{name}.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
            }
        }

これでだいぶ短縮!

次に VSCode のソリューションエクスプローラーの右クリックメニューから「リビルド」実行

出力先プロジェクトを開いている UnityEditor に戻り、[Ctrl] + [R] で再コンパイル

属性の付いた Test クラスを partial 指定に変更し、プロパティが生えているかどうか確認

よしよし問題なし