Roslynで簡易コールグラフを作成するメモ

6 min read読了の目安(約5700字

環境構築

https://docs.microsoft.com/ja-jp/dotnet/core/install/linux-ubuntu を参考にインストールします。

# パッケージ リポジトリを追加
wget https://packages.microsoft.com/config/ubuntu/20.10/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb

# SDK のインストール
sudo apt-get update; \
  sudo apt-get install -y apt-transport-https && \
  sudo apt-get update && \
  sudo apt-get install -y dotnet-sdk-5.0

C#プロジェクトを作成します。

dotnet new console

今回はRoslyn APIを使用して解析したいのでパッケージを追加します。
VisualStudioインストール済みのWindows環境であれば MSBuildWorkspace を使用するとワークスペースの解析が簡単にできそうですが、筆者のUbuntu環境ではエラーになってしまうため使用しませんでした。

dotnet add package Microsoft.CodeAnalysis.CSharp --version 3.9.0
dotnet add package Microsoft.CodeAnalysis.CSharp.Workspaces --version 3.9.0

解析対象

今回は下記の2クラスを解析します。

using System;
namespace Test.HelloWorld
{
    public class A
    {
        public static void F()
        {
            F();
            Console.WriteLine('X');
            new A().H("");
        }

        void H(string s)
        {
            Console.WriteLine(s);
        }
    }
}
namespace Test.HelloWorld
{
    public class B
    {
        public static void F()
        {
            A.F();
        }
    }
}

解析処理

まずコールグラフのエッジ構築するのは下記のように行っています。
今回はSemanticModelを用いてCallerのメソッドとCalleeのメソッドのシンボルを取得し、それを元にエッジを構築します。

using System;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace RoslynCSharpCallGraph.Walker
{
    public class CallGraphWalker : CSharpSyntaxWalker
    {
        private readonly SemanticModel semanticModel;

        public CallGraphWalker(SemanticModel semanticModel)
        {
            this.semanticModel = semanticModel;
        }

        private static string ExtractCallerID(SemanticModel semanticModel, SyntaxNode node)
        {
            while (node != null)
            {
                if (node is MethodDeclarationSyntax methodDeclarationNode)
                {
                    var methodSymbol = semanticModel.GetDeclaredSymbol(methodDeclarationNode);
                    return methodSymbol.ToDisplayString();
                }

                node = node.Parent;
            }

            throw new ArgumentException("");
        }

        public override void VisitInvocationExpression(InvocationExpressionSyntax node)
        {
            var calleeMethodSymbol = semanticModel.GetSymbolInfo(node).Symbol as IMethodSymbol;

            string callerMethodID = ExtractCallerID(semanticModel, node);
            string calleeMethodID = calleeMethodSymbol.ToDisplayString();
            Console.WriteLine("\t\"{0}\" -> \"{1}\";", callerMethodID, calleeMethodID);

            base.VisitInvocationExpression(node);
        }
    }
}

ワークスペースの構築は下記のようにしています。

            // workspaceの構築
            var projectName = "TestProject";
            var assemblyName = "TestProject";
            var files = new List<string> { @"HelloWorld/A.cs", @"HelloWorld/B.cs" };

            var projectId = ProjectId.CreateNewId(projectName);

            var metadataReferences = new List<MetadataReference> {
                MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
                MetadataReference.CreateFromFile(typeof(Console).Assembly.Location)
            };

            var documentInfos = new List<DocumentInfo>();
            foreach (var file in files)
            {
                var debugName = file;
                var documentId = DocumentId.CreateNewId(projectId, debugName);

                var source = File.ReadAllText(file);

                var version = VersionStamp.Create();
                var textAndVersion = TextAndVersion.Create(SourceText.From(source), version);
                var loader = TextLoader.From(textAndVersion);
                var filePath = file;

                var documentName = file;
                var documentInfo = DocumentInfo.Create(
                    documentId,
                    documentName,
                    sourceCodeKind: SourceCodeKind.Regular,
                    loader: loader,
                    filePath: filePath
                );

                documentInfos.Add(documentInfo);
            }

            var workspace = new AdhocWorkspace();

            var solution = workspace.CurrentSolution
                .AddProject(projectId, projectName, assemblyName, LanguageNames.CSharp)
                .AddMetadataReferences(projectId, metadataReferences)
                .AddDocuments(documentInfos.ToImmutableArray());

解析のメインの処理では、下記のようにDocumentを一つずつ解析して各ファイル毎にエッジを構築しています。
(今回は簡単のためにdot言語を標準出力に出力しています)

            // 各Documentを解析してgraphvizを出力
            Console.WriteLine("digraph graph_name {");
            Console.WriteLine("\tgraph [ rankdir = LR ];");

            foreach (var documentInfo in documentInfos)
            {
                var document = solution.GetDocument(documentInfo.Id);
                var semanticModel = document.GetSemanticModelAsync().Result;
                var syntexTree = document.GetSyntaxTreeAsync().Result;
                var walker = new CallGraphWalker(semanticModel);
                walker.Visit(syntexTree.GetRoot());
            }

            Console.WriteLine("}");

上記で実装は終わりです。実行すると https://github.com/tanzaku/roslyn-c-sharp-callgraph/blob/main/graphviz.svg を生成するdot言語が出力されます。
コード全体は https://github.com/tanzaku/roslyn-c-sharp-callgraph で見ることができます。

まとめ

Roslyn APIを用いることで、少ないコード量で静的解析することができました。
静的解析ができると、独自のLintルールを追加し品質向上につながるツールを作成するなど様々な応用ができそうですね。