UnityでDocumentation Comment Idを生成・表示したい

# 結論
- Documentation Comment Idを表示するEditor拡張を用意した
- インストールは今のところgit urlからのみ
  - パッケージrepo https://github.com/satanabe1/documentation-comment-id-viewer
  - サンプルrepo https://github.com/satanabe1/pkg2023
  - openupmにあるライブラリを使ってるのでopenupm設定が必要

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5973/a393d5d6-e466-1466-d9a5-613e23d4be81.gif)


```json:manifest.json
{
  "scopedRegistries": [
    {
      "name": "package.openupm.com",
      "url": "https://package.openupm.com",
      "scopes": [
        "com.openupm",
        "org.nuget"
      ]
    }
  ],
  "dependencies": {
    "documentation-comment-id-viewer": "git@github.com:satanabe1/documentation-comment-id-viewer.git"
  }
}
```

# Documentation Comment Id
### Documentation Comment Idとは？
公式なドキュメントについては以下のリンク周辺にあります。
- Microsoft Learn Challenge
  - https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/language-specification/documentation-comments#d43-id-string-examples
- C# Standard
  - https://github.com/dotnet/csharpstandard/blob/standard-v6/standard/documentation-comments.md#d42-id-string-format

リンク先の例を元に一部抜粋すると、以下の `T:Acme.Widget`・`M:Acme.Widget.M0` のようにクラスやメソッドを表記する方法です。詳しいフォーマットについては上記参考リンクを見てください。
 
```c#
namespace Acme
{
    // "T:Acme.Widget"
    class Widget
    {
        // "M:Acme.Widget.M0"
        public static void M0() { ... }
    }
}
```

### Documentation Comment Idの使い道
Documentation Comment Idなんて、手書きしたことある人は多くないと思います。それどころか見たことすらないかも知れません。
主な用途はコード中のXMLドキュメントコメント( `<summary>...</summray>` のようなやつ)からドキュメントを自動生成する過程で使われるものであり、本来、人間が意識し手書きするものではありません。
しかしコードアナライザーやソースジェネレーター(UnityならILPostprocessorなんかも)の登場で、稀に手書きを強いられる状況が出てきました。

わかりやすい利用例としてBannedApiAnalyzersがあります。
https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.BannedApiAnalyzers/BannedApiAnalyzers.Help.md
BannedApiAnalyzersでは、`BannedSymbols.txt` にDocumentation Comment Idを記述することで、そのクラス(あるいは、メソッド・プロパティ・フィールドetc)を利用した際にコンパイルエラーや警告を発生させる、ということができます。
私が関わった実際のプロジェクトでは`BannedSymbols.txt`には以下のような記述がありました。(一部抜粋)

```
M:System.DateTimeOffset.#ctor(System.DateTime);DateTimeからDateTimeOffsetを作成するコンストラクタは結果が環境依存なので使用しないでください
M:System.DateTimeOffset.op_Implicit(System.DateTime);DateTimeからDateTimeOffsetへの暗黙的な変換は結果が環境依存なので使用しないでください
P:System.DateTime.Now;TimeUtility.NowJSTDateTimeを使用してください
P:System.DateTime.UtcNow;TimeUtility.NowUnixTimeを使用してください
M:System.DateTime.ToUniversalTime();意図した変換は行えないので使用しないでください
P:System.DateTimeOffset.Now;TimeUtility.NowJSTDateTimeを使用してください
P:System.DateTimeOffset.UtcNow;TimeUtility.NowUnixTimeを使用してください
M:System.DateTimeOffset.ToUniversalTime();意図した変換は行えないので使用しないでください
```

### Documentation Comment Idの問題点
前述のように外部テキストでアナライザーやジェネレーターの挙動を変えようと思うと、Documentation Comment Idで指定するという方法が選択肢に入ってきますし、何かもっと面白そうなことができそうな気もしてきます。
しかしこのDocumentation Comment Id、手書きするのが意外と面倒くさい、という問題点があります。
記法自体はそこまで複雑なものではないのですが、対象のソースコードの定義次第ではとんでもない長さを手書きする羽目になります。
実際のプロジェクトで指定した(今のところ)最長のDocumentation Comment Idを以下に示します。

```c#:UniTask.WaitUntil.cs
namespace Cysharp.Threading.Tasks
{
    public partial struct UniTask
    {
        // WaitUntilValueChangedを指定したかった
        public static UniTask<U> WaitUntilValueChanged<T, U>(T target, Func<T, U> monitorFunction, PlayerLoopTiming monitorTiming = PlayerLoopTiming.Update, IEqualityComparer<U> equalityComparer = null, CancellationToken cancellationToken = default(CancellationToken), bool cancelImmediately = false);
    }
}
```

```text:Documentation Comment Id
// Documentation Comment Id形式
M:Cysharp.Threading.Tasks.UniTask.WaitUntilValueChanged``2(``0,System.Func{``0,``1},Cysharp.Threading.Tasks.PlayerLoopTiming,System.Collections.Generic.IEqualityComparer{``1},System.Threading.CancellationToken,System.Boolean)
```

これを正確に手書きするのは、なかなか難しいです。
そこで今回、Documentation Comment IdをUnity上で確認できるようなEditor拡張を作ってみます。

# 実装してみる
### 作るもの
主に次のような仕様を実現します。
- GUIでの簡単な検索機能
- クリップボードへDocumentation Comment Idをコピー
- 対象assemblyの絞り込み
- Property,Fieldなどの種類毎の絞り込み
- ユーザー定義のコードor自動生成(BackingFieldなど)での絞り込み

完成形としては、最初の結論のところに貼ったgifのツールになりました。

### 実装方針と採用ライブラリ
#### Documentation Comment Idを生成する方法
フォーマット自体は https://github.com/dotnet/csharpstandard/blob/standard-v6/standard/documentation-comments.md#d42-id-string-format ここに定義されている通りで、Reflectionなどでdllからクラス・メンバ情報を取得し、粛々とフォーマット通りに文字列を作るのもありだと思います。しかし、完全に対応しようとすると、ジェネリクス関係など、意外と面倒な実装が必要になってきますし、(そう無いと思いますが)今後新たな文法が登場した際にこちら側の実装を修正するのも面倒です。
そこで今回は横着して、RoslynAnalyzer(microsoft.codeanalysis.csharp)を利用することにします。RoslynAnalyzerは[openupm](https://openupm.com/)に `org.nuget.microsoft.codeanalysis.csharp` としてpublishされているので、以下のような `scopedRegistries` の設定を記述しておくことでUnityのPackage Managerから簡単に利用可能です。

```json:manifest.json
{
  "scopedRegistries": [
    {
      "name": "package.openupm.com",
      "url": "https://package.openupm.com",
      "scopes": [
        "org.nuget"
      ]
    }
  ],
  "dependencies": {
    "org.nuget.microsoft.codeanalysis.csharp": "4.11.0"
  }
}
```

尚、RoslynAnalyzerを利用すると書くとsln/csproj/csの解析を行うようなイメージが湧いてきますが、今回はそこまでソースコードには興味が無いので、Unityがコンパイルしてくれたdllを `AppDomain.CurrentDomain.GetAssemblies()` で取得してRoslynAnalyzerに読み込ませて、[Symbol](https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.codeanalysis.isymbol?view=roslyn-dotnet-4.9.0)情報だけを利用するという少々トリッキーな使い方をします。
以下がその実装になります。
https://github.com/satanabe1/documentation-comment-id-viewer/blob/a94bb3e78653a23422819104d2976d39f95ac2fe/Editor/DocumentationCommentIdUtility.cs#L17-L58

```c#
        public static IEnumerable<AssemblyDocumentationCommentIdData> GetDocumentationCommentIdData(
            IEnumerable<Assembly> assemblies)
        {
            // AppDomainから取得したassembliesを参照するCSharpCompilationを生成
            var refs = assemblies
                .Where(x => !x.IsDynamic && !string.IsNullOrEmpty(x.Location))
                // MetadataReferenceとしてassemblyを読み込ませる
                .Select(x => (asm: x, metadata: MetadataReference.CreateFromFile(x.Location)))
                .ToDictionary(x => x.asm.GetName().Name, x => x);
            var compilation = CSharpCompilation
                .Create("tmp-compilation", references: refs.Values.Select(x => x.metadata));
            // CSharpCompilation内からSymbol情報を取得する
            return compilation.Assembly.Modules
                .SelectMany(x => x.ReferencedAssemblySymbols)
                .Distinct<IAssemblySymbol>(SymbolEqualityComparer.Default)
                .Select(x => GetAssemblyDocumentationCommentIdData(x, refs.GetValueOrDefault(x.Name)))
                .Where(x => x != null)!;
        }
```

Symbol情報さえ取得できれば、Sybmbolから [ISymbol.GetDocumentationCommentId()](https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.codeanalysis.isymbol.getdocumentationcommentid?view=roslyn-dotnet-4.9.0) を通して、Documentation Comment Idを取得することができます。

#### GUIの実装方針
今回のツール作成は、Documentation Comment Idの手書きの面倒さや、面倒ゆえの書き損じを減らしたい、と言うところにモチベーションがあります。従って、それなりに検索しやすいGUIやコピペサポートによるタイポの抑制を目指しています。そのためにはそれなりのGUIが必要です。
UnityEditor上で動くツールを作る以上、IMGUIかUIToolkitの二択になりますが、TreeViewやSplitViewが(相対的に見ると)手軽に扱えるUIToolkitを採用することにしました。

UIToolkitの[TreeView](https://docs.unity3d.com/ja/2023.2/Manual/UIE-uxml-element-TreeView.html)の扱い方については、世に沢山の記事(例えば [コレとか](https://light11.hatenadiary.com/entry/2023/05/23/192515))があるので、そちらを参照してください。

[SplitView](https://docs.unity3d.com/Packages/com.unity.ui@1.0/api/UnityEngine.UIElements.TwoPaneSplitView.html)については標準ではUIBuilderは対応していないのですが、以下のようなクラスを定義することでUIBuilder上でも扱うことができるようになります。

```c#:SplitView.cs
using UnityEngine.UIElements;

#if UNITY_2023_2_OR_NEWER
[UxmlElement]
#endif
internal partial class SplitView : TwoPaneSplitView
{
#if !UNITY_2023_2_OR_NEWER
    public new class UxmlFactory : UxmlFactory<SplitView, UxmlTraits> { }
#endif
}
```

![splitview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5973/9000fe4c-6813-7f81-01e0-b9c0c4d9d8c1.png)

これらを組み合わせてSplitViewで、上部にTreeViewでクラスやメソッドを表示し、下部にDocumentation Comment Idを表示してみると以下のようになりました。

![スクリーンショット 2024-12-02 午後12.00.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5973/98ff8f5e-3366-ff22-58a5-7a5548981356.png)

また、assemblyの解析やTreeの構築などは比較的重い処理になりやすいので、今回はThreadPoolに投げ込むことでUnityをブロックしないように配慮しました。

https://github.com/satanabe1/documentation-comment-id-viewer/blob/a94bb3e78653a23422819104d2976d39f95ac2fe/Editor/DocumentationCommentIdWindow.cs#L168-L187

```c#:例
        private void RebuildTreeView(List<AssemblyTreeViewItem> treeData, Action? onRebuild = null)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                try
                {
                    // 物量次第ではここが比較的重くなるかも
                    var items = BuildTreeItems(treeData);
                    // バックグラウンドスレッドからUIToolkitを操作することはできないので、
                    // EditorApplication.delayCallでUnityスレッドに戻してRebuild
                    EditorApplication.delayCall += () =>
                    {
                        RebuildTreeView(items);
                        onRebuild?.Invoke();
                    };
                }
                catch (Exception e)
                {
                    Debug.LogException(e);
                    onRebuild?.Invoke();
                }
            });
        }
```

詳細な実装については
https://github.com/satanabe1/documentation-comment-id-viewer/blob/main/Editor/DocumentationCommentIdWindow.cs
を見ていただければと思います。

# まとめ
- Documentation Comment Idを書く機会が増えてきたが、手書きではダルすぎる
- なのでDocumentation Comment IdをコピペするためのUnity用Editor拡張を作った
- 検索性の高いGUIを実装したことでDocumentation Comment Idを扱いやすくなった
- 他
  - openupmを使えばお手軽にRoslyn Analyzerを使えて幸せだった
  - UIToolkitがやっと使いやすい感じになってきたかも