【Unity】インスペクタで編集できるけどスクリプトで変更できないパラメータ【静的解析】

# 環境
```
Unity: 2021.3.15f1
```
# やること
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356775/050e1057-0280-7643-0008-0031ace47e61.png)
このように、スクリプトでは値を変更しようとするとエラーが出て怒られるけど、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356775/cbb7a180-8eba-dea6-657f-044f7fe8fd9b.png)
インスペクタ上では普通に編集できるパラメータを作ります。

# 実装
タイトルで落ちてますが、**RoslynAnalyzer**で値の割り当てを禁止することで実現します。
```cs
public class AnalyzerTest : MonoBehaviour
{
    [SerializeField, ReadonlyInScript] private int _testField;
    [field: SerializeField, ReadonlyInScript] private int TestProp { get; set; }
}
```
あからさまに`ReadonlyInScript`属性が重要そうではありますが、これは何の中身も無い属性です。
```csharp
using System;

[AttributeUsage(AttributeTargets.Field)]
public class ReadonlyInScriptAttribute : Attribute { }
```
コードは後述しますが「`ReadonlyInScript`という名前の属性が付いたフィールドに対して書き込みを行おうとするとエラーを出す」とアナライザの方で記述しています。

## RoslynAnalyzerとは
https://learn.microsoft.com/ja-jp/visualstudio/code-quality/roslyn-analyzers-overview?view=vs-2022

詳しくは色々記事があるので割愛しますが、要するに、普通のコンパイラだと素通りする記述でも、自由に独自ルールを決めて警告を出したりエラーにしたりできる仕組みです。

## `ReadonlyInScript`属性が付いたフィールドやプロパティへの書き込みを禁止するRoslynAnalyzer
アナライザの大部分のテンプレ的な記述を省くと、以下のような記述になっています
```cs
public override void Initialize(AnalysisContext context)
{
    // お約束。
    context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
    context.EnableConcurrentExecution();

    // binary assignment expression全部
    // https://github.com/dotnet/roslyn/blob/main/src/Compilers/CSharp/Portable/Syntax/SyntaxKind.cs
    var kinds = new SyntaxKind[]
    { 
        SyntaxKind.SimpleAssignmentExpression,     // a = b
        SyntaxKind.AddAssignmentExpression,        // a += b
        SyntaxKind.SubtractAssignmentExpression,   // a -= b
        SyntaxKind.MultiplyAssignmentExpression,   // a *= b
        SyntaxKind.DivideAssignmentExpression,     // a /= b
        SyntaxKind.ModuloAssignmentExpression,     // a %= b
        SyntaxKind.AndAssignmentExpression,        // a &= b
        SyntaxKind.ExclusiveOrAssignmentExpression,// a ^= b
        SyntaxKind.OrAssignmentExpression,         // a |= b
        SyntaxKind.LeftShiftAssignmentExpression,  // a <<= b
        SyntaxKind.RightShiftAssignmentExpression, // a >>= b
        SyntaxKind.CoalesceAssignmentExpression,   // a ??= b

        SyntaxKind.PreIncrementExpression,         // ++a
        SyntaxKind.PreDecrementExpression,         // --a
        SyntaxKind.PostIncrementExpression,        // a++
        SyntaxKind.PostDecrementExpression         // a--
    };

    // 上記のノードの場合に診断を走らせるよう登録
    context.RegisterSyntaxNodeAction(AnalyzeSyntax, kinds);
}

// 診断処理
private static void AnalyzeSyntax(SyntaxNodeAnalysisContext context)
{
    // 割り当てされようとしてるパラメータを取得する
    var syntax = context.Node switch
    {
        AssignmentExpressionSyntax assignExpression => assignExpression.Left,
        PostfixUnaryExpressionSyntax postUnary => postUnary.Operand,
        PrefixUnaryExpressionSyntax preUnary => preUnary.Operand,
        _ => default
    };

    if (syntax == default) return;

    // セマンティックモデルを使って、シンボル（型とかわかる情報）を取得する
    var symbol = context.SemanticModel
        .GetSymbolInfo(syntax, context.CancellationToken)
        .Symbol;

    var result = symbol switch
    {
        // フィールドの場合は素直に属性を取得して、属性の名前で判定
        IFieldSymbol fieldSymbol
            => fieldSymbol.GetAttributes()
                .Any(x => x.AttributeClass.Name.Contains("ReadonlyInScriptAttribute")),
        // プロパティの場合はちょっと面倒
        IPropertySymbol propertySymbol 
            => IsAutoProperty(propertySymbol) && HasReadonlyAttribute(propertySymbol),
        _ => false,
    };

    if (result)
    {
        var diagnostic = Diagnostic.Create(Rule, context.Node.GetLocation());
        context.ReportDiagnostic(diagnostic);
    }

    // そのプロパティが自動実装プロパティかどうか調べる（今は、インスペクタ表示に関して調べたいのでこうしてる）
    bool IsAutoProperty(IPropertySymbol propertySymbol)
    {
        // シンボルから、それが宣言されてるノードを調べることができる。
        var node = propertySymbol.DeclaringSyntaxReferences.First().GetSyntax();
        if (node is not PropertyDeclarationSyntax propertyDeclarationSyntax) return false;
        // アクセサ（setやget）の中身がどれも空っぽなら自動実装プロパティ
        return propertyDeclarationSyntax.AccessorList.Accessors.All(a => a.Body == null);
    }

    // バッキングフィールドの属性はGetAttributesでは取得できないようなので、愚直に構文木を調べる
    bool HasReadonlyAttribute(IPropertySymbol propertySymbol)
    {
        var node = propertySymbol.DeclaringSyntaxReferences.First().GetSyntax();
        if (node is not PropertyDeclarationSyntax propertyDeclarationSyntax) return false;
        // 子孫ノード全探索して属性ノードを見つけて、名前で判断。ちょっと嫌な感じはする。
        return propertyDeclarationSyntax.DescendantNodes()
            .OfType<AttributeSyntax>()
            .Any(a => a.Name.ToString().Contains("ReadonlyInScript"));
    }
}
```



### リポジトリ
https://github.com/umekan/ReadonlyInScript

今回のアナライザのコードはgithubで公開しています。
一応dllも置いているので、以下のリンクなどを参考にdllをUnityにブチ込んで、各自で`ReadonlyInScriptAttribute`という名前の属性を作ってもらえばエラー吐いてくれるのが確認できると思います。（コード修正して各々の環境でdll作ってもらっても勿論構いません）

https://docs.unity3d.com/ja/2021.1/Manual/roslyn-analyzers.html

# 終わり
インスペクタでだけ編集可能で何が嬉しいのかはわかりませんが（非プログラマが編集するデータ主体のコンポーネントとか？）、具体例は置いといて、アナライザの自作を手段として持ってると、特に全体に目が行き届かないようなチーム開発では役立つかもしれません。

アナライザのコードで`SyntaxKind`がズラッと並んでるところで嫌になった人もいると思いますが、構文木はツールとかSharpLabとかで確認できるので、アナライザを自作するときは、実際に判定したいコードの例を書いてみて、それの構文木と睨めっこして診断処理を書いていくと良いと思います。私もこんなん覚えてないので見ながら書いてます。

https://sharplab.io/

https://learn.microsoft.com/ja-jp/dotnet/csharp/roslyn-sdk/syntax-visualizer?tabs=csharp

https://plugins.jetbrains.com/plugin/16902-rossynt

# 他に参考にした記事
https://neue.cc/2021/05/08_600.html