気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

Roslyn CodeAnalysisでC#ソースの解析をしてみる

お久しぶりです。
多忙によりブログネタが全くできず、今まで放置状態でした。
さて、今日はVisual Studio 2015から導入されたRoslynコンパイラAPIの一部で、C#ソースを解析するCodeAnalysisを使って解析を行ってみます。
大学の卒研で触ることがあったのですが、ネット漁ってもあんまり情報がなかったのでこれから利用する方の参考になればと思います。

はじめに

CodeAnalysisはRoslynに含まれるAPIのうちのSyntax APIに含まれる機能で、C#またはVB.Net構文解析を行うことができます[構文解析の概要]。
本来ならば自力で字句解析器と構文解析器を作成しなければならないところ、これらの言語なら公式で提供されるAPIにソースを渡すだけで解析ができるので非常に楽で、しかも正確です。
しかしながら、簡単なサンプルはネットを漁るとすぐに見つかるものの、ソースコードを全て取得するような話になると全く情報が出てこなくなります。
卒研で作成したかったのはJavaDocのようなドキュメントを作成することで、ソースコードに含まれる情報は全てほしいくらいでした。
なんとかならんものかと奮闘したのがこの記事の中身です。

ちなみに卒研の時に作ったツールはCsXmlDocumentToHtmlです。

検証環境

項目 内容
OS Windows 10 x64 / macOS 10.14.4
C# C# 7.3
.Net .Net Framework 4.7.1

フレームワークは.Net Coreでも可能ですが、ライブラリの取り込みでアセンブリパスが変わります。

必要なライブラリ

  1. Microsoft.CodeAnalysis

f:id:AonaSuzutsuki:20190426094623p:plain

NugetでMicrosoft.CodeAnalysisを検索し、インストールします。
すると必要な依存ライブラリをインストールするよう促されるのでそのままそれらもインストールします。

下準備

解析するソースコード

  1. Program.cs
using System;

namespace CodeAnalysisSample
{
    public class Program : IProgram
    {
        private int value = 0;
        public int Value { get; private set; }

        public virtual void Method()
        {
            Value = 1;
            Console.WriteLine(Value);
        }

        public static void Main(string[] args)
        {
            new Program().Method();
        }
    }
}
  1. IProgram.cs
using System;

namespace CodeAnalysisSample
{
    public interface IProgram
    {
        int Value { get; }
    }
}

今回解析する対象のコードです。
中身は全く意味がないので放置で大丈夫です。
とりあえず戻り値とキーワードなど含めているので一通りの解析はできるかと。

構文解析

構文解析はまずソースコードを文字列として読み込みます。
その後、「CSharpSyntaxTree.ParseText関数」でAPI構文解析を依頼し、戻り値として解析結果であるSyntaxTreeを受けます。

// 読み込むソースファイル群
string[] filenames = { "Program.cs", "IProgram.cs" };
// 構文木を格納するリスト
var syntaxTrees = new List<SyntaxTree>();
foreach (var filename in filenames)
{
    // ソースコードをテキストとして読み込む
    var code = File.ReadAllText(filename);
    // 構文木の生成
    var syntaxTree = CSharpSyntaxTree.ParseText(code, CSharpParseOptions.Default, filename);
    // 構文木をリストへ格納
    syntaxTrees.Add(syntaxTree);
}

// LINQ
// var syntaxTrees = (from filename in filenames
//            let code = File.ReadAllText(filename)
//            let syntaxTree = CSharpSyntaxTree.ParseText(code, CSharpParseOptions.Default, filename)
//            select syntaxTree).ToArray();

これでソースコード構文木が取得でき、構文解析は終了です。

ライブラリの取り込み

これはなくても支障はないんですが、外部ライブラリのクラスや標準ライブラリのクラスを利用する場合はライブラリのアセンブリのパスを渡さないと正しい情報が取得できません。
もし渡さない場合はソースコードで記述されているまんま取得されます。
例えば、戻り値にList<string>と記述しているとList<string>として、Generic.List<string>ならGeneric.List<string>として取得されます。
もちろんこれらはただの名前しか情報がないのでそれ以上は解析できません。

それだと困るので今回はアセンブリも取り込みます。
アセンブリを読み込む際は「MetadataReference.CreateFromFile関数」にアセンブリのパスを渡すことでAPIで認識できる型で読み込まれます。

var references = new[]{
    // microlib.dll
    MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
    // System.dll
    MetadataReference.CreateFromFile(typeof(ObservableCollection<>).Assembly.Location),
    // System.Core.dll
    MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location),
    // External library
    // MetadataReference.CreateFromFile("library path"),
};

これでコンソールアプリケーション程度の標準ライブラリが取得できます。
ただし、これで取得できるアセンブリは実行時のバージョンに依存するので、対象ソースコードで使われているバージョンのアセンブリを指定したい場合はcsprojファイルからバージョンを取得し、システム領域で保管されているアセンブリを特定する必要があります。
nugetはpackages.jsonとpackagesディレクトリ調べるとすぐわかると思います。

標準ライブラリの場所

標準ライブラリの特定のアセンブリを指定する場合は自力で調べる必要があります。
ツールを作るにあたって調べた結果をここに記載しておきます。

Windowsの場合は

  1. C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework.NETFramework\v{バージョン}
  2. C:\Windows\assembly\GAC_MSIL

の2箇所

macOSの場合は

  1. /Library/Frameworks/Mono.framework/Versions/Current/lib/mono/gac
  2. /Library/Frameworks/Mono.framework/Versions/Current/lib/mono/{バージョン}-api
  3. /usr/lib/mono/gac
  4. /usr/lib/mono/{バージョン}-api

の4箇所

に含まれていることが多いです。
必ずここに入っているわけではないので見つからない場合は気合いで探すしかありません。
また、厄介なのが同名のアセンブリで.Netで読み込めないものがあるのでその点だけ要注意です。(多分内部処理用のC++バイナリ?)
.Net Coreの場合はアセンブリパスが異なるので「typeof(object).Assembly.Location」の要領でアセンブリパスを取得して調べる必要があります。

コンパイラの生成

ここまで下準備ができたので最後に解析用のコンパイラを生成します。
コンパイラを生成する場合は「CSharpCompilation.Create関数」に名前とここまでで準備してきた構文木と外部アセンブリ情報を指定します。

var compilation = CSharpCompilation.Create("sample", syntaxTrees, references);

これでコンパイラの生成は終了です。
ここから解析結果を整形していきます。

解析結果の整形

ノードの取得

以下に続くクラスやメソッドの情報を取得する際に共通するのがノードの取得です。
これは単純に構文木のルートを取得し、そこから子ノードを取得します。
セマンティックモデルはシンボルを取得する際に使うので予めコンパイラから各構文木にあったものを取得しておきます。

foreach (var tree in syntaxTrees)
{
    // コンパイラからセマンティックモデルの取得
    var semanticModel = compilation.GetSemanticModel(tree);
    // 構文木からルートの子ノード群を取得
    var nodes = tree.GetRoot().DescendantNodes();

    ...
}

クラス, インタフェース, 列挙型, 構造体, デリゲートの取得

クラスやインタフェースなどの型を構成する構文はノードからOfTypeメソッドのジェネリクスにClassDeclarationSyntaxなどを指定することで構文情報群が取得できます。

// ノード群からクラスに関する構文情報群を取得
// クラスはClassDeclarationSyntax
// インタフェースはInterfaceDeclarationSyntax
// 列挙型はEnumDeclarationSyntax
// 構造体はStructDeclarationSyntax
// デリゲートはDelegateDeclarationSyntax
var classSyntaxArray = nodes.OfType<ClassDeclarationSyntax>();

取得できるとあとは各構文情報についてシンボルを取得し、シンボルから情報を抽出します。

foreach (var syntax in classSyntaxArray)
{
    // 各構文のシンボルを取得
    var symbol = semanticModel.GetDeclaredSymbol(syntax);
    // 名前やキーワードの有無をコンソールへ出力
    Console.WriteLine("{0} {1}", symbol.DeclaredAccessibility,symbol);
    Console.WriteLine(" Namespace: {0}", symbol.ContainingSymbol);
    Console.WriteLine(" {0}: {1}", nameof(symbol.IsAbstract), symbol.IsAbstract);
    Console.WriteLine(" {0}: {1}", nameof(symbol.IsStatic), symbol.IsStatic);
}
Public CodeAnalysisSample.Program
 Namespace: CodeAnalysisSample
 IsAbstract: False
 IsStatic: False

今回はabstractとstaticであるかどうかを取得してみましたが、そのほかの詳細なキーワードなどの取得もできます。

メソッド, コンストラクタの取得

メソッドもクラスと同様の方法で取得できます。

// コンストラクタはConstructorDeclarationSyntax
var methodSyntaxArray = nodes.OfType<MethodDeclarationSyntax>();
foreach (var syntax in methodSyntaxArray)
{
    var symbol = semanticModel.GetDeclaredSymbol(syntax);
    Console.WriteLine("{0} {1}", symbol.DeclaredAccessibility,symbol);
    Console.WriteLine(" Namespace: {0}", symbol.ContainingSymbol);
    Console.WriteLine(" {0}: {1}", nameof(symbol.IsStatic), symbol.IsStatic);
    Console.WriteLine(" IsExtensionMethod: {0}", symbol.IsExtensionMethod);

    // 引数の型と名前をひとまとめに
    var parameters = from param in symbol.Parameters select new { Name = param.Name, ParamType = param.ToString() };

    // 引数の出力
    Console.WriteLine(" Parameters:");
    foreach (var elem in parameters)
        Console.WriteLine("  {0} {1}", elem.ParamType, elem.Name);

    // 戻り値の出力
    Console.WriteLine(" ReturnType: {0}", symbol.ReturnType);
}
Public CodeAnalysisSample.Program.Method()
 Namespace: CodeAnalysisSample.Program
 IsStatic: False
 IsExtensionMethod: False
 Parameters:
 ReturnType: void
Private CodeAnalysisSample.Program.Main(string[])
 Namespace: CodeAnalysisSample.Program
 IsStatic: True
 IsExtensionMethod: False
 Parameters:
  string[] args
 ReturnType: void

メソッドは引数の処理があるのが若干めんどうなところ。
クエリ式使えば一行だけど、規則とかで使えなかったら結構行取られるので関数作るなりして工夫しないとごちゃごちゃします。

プロパティの取得

プロパティも今までとほぼ同じ要領で取得できますが、アクセサの取得が結構めんどくさい。

var propertySyntaxArray = nodes.OfType<PropertyDeclarationSyntax>();
foreach (var syntax in propertySyntaxArray)
{
    var symbol = semanticModel.GetDeclaredSymbol(syntax);
    Console.WriteLine("{0} {1}", symbol.DeclaredAccessibility,symbol);
    Console.WriteLine(" Namespace: {0}", symbol.ContainingSymbol);
    Console.WriteLine(" {0}: {1}", nameof(symbol.IsStatic), symbol.IsStatic);

    // アクセサの取得
    var accessors = from accessor in syntax.AccessorList.Accessors
                    select new {
                        Name = accessor.Keyword.ToString(),
                        Access = accessor.Modifiers.Count > 0 ?
                            semanticModel.GetDeclaredSymbol(accessor).DeclaredAccessibility :
                            Accessibility.Public
                        };

    // クエリ式使わない場合
    //var accessors = new List<(string Name, Accessibility Access)>();
    //foreach (var accessor in syntax.AccessorList.Accessors)
    //{
    //    var accessibility = Accessibility.Public;
    //    var keyword = accessor.Keyword.ToString();
    //    if (accessor.Modifiers.Count > 0)
    //    {
    //        var msym = semanticModel.GetDeclaredSymbol(accessor);
    //        accessibility = msym.DeclaredAccessibility;
    //    }
    //    accessors.Add((keyword, accessibility));
    //}

    // アクセサの出力
    Console.WriteLine(" Accessors:");
    foreach (var accessor in accessors)
        Console.WriteLine("  {0} {1}", accessor.Access, accessor.Name);

    // 戻り値に関するSymbolInfoを取得
    var symbolInfo = semanticModel.GetSymbolInfo(syntax.Type);
    // SymbolInfoからシンボルを取得
    var sym = symbolInfo.Symbol;
    // 戻り値の出力
    Console.WriteLine(" Type: {0}", sym.ToDisplayString());
}
Public CodeAnalysisSample.Program.Value
 Namespace: CodeAnalysisSample.Program
 IsStatic: False
 Accessors:
  Public get
  Private set
 Type: int
Public CodeAnalysisSample.Sample.IProgram.Value
 Namespace: CodeAnalysisSample.Sample.IProgram
 IsStatic: False
 Accessors:
  Public get
 Type: int

メソッド以上にプロパティのアクセサは取得がめんどう。
さらに戻り値の取得もGetDeclaredSymbolメソッドで得られるシンボルからそのまま取得できないので、GetSymbolInfoメソッドに戻り値の型を渡してシンボルを取得します。
そこから型の文字列を取得しています。
syntax.Type.ToStringメソッドでも文字列だけならいいですが、それ以上の情報が欲しい時はシンボルを取っておくと何かと便利です。

フィールドの取得

フィールドは今までとsyntaxの扱いが変わります。

var fieldSyntaxArray = nodes.OfType<FieldDeclarationSyntax>();
foreach (var syntax in fieldSyntaxArray)
{
    // アクセス修飾子と名前を出力
    Console.WriteLine("{0} {1}", syntax.Modifiers.First(),syntax.Declaration.Variables.ToFullString());
    // 型に関するSymbolInfoを取得
    var symbolInfo = semanticModel.GetSymbolInfo(syntax.Declaration.Type);
    // 型からシンボルを取得
    var sym = symbolInfo.Symbol;
    // 型の出力
    Console.WriteLine(" Type: {0}", sym.ToDisplayString());

}
private value
 Type: int

フィールドはメソッドやプロパティのように柔軟に情報が取得できないようです。
少なくともアクセス修飾子や名前、型は取得できましたが、名前空間などは取得できませんでした。
そもそもシンボルが取れなかったです。

基底クラスの取得

基底クラスはクラスやインタフェースの取得に合わせてsyntaxからBaseList.Typesを元に取得していきます。
BaseList.Typesだけで完結するかと思うとそういうわけにはいかず、セマンティックモデルからシンボルを取得する必要があります。

foreach (var syntax in classSyntaxArray)
{
    var symbol = semanticModel.GetDeclaredSymbol(syntax);
    Console.WriteLine("{0} {1}", symbol.DeclaredAccessibility, symbol);
    Console.WriteLine(" Namespace: {0}", symbol.ContainingSymbol);
    Console.WriteLine(" {0}: {1}", nameof(symbol.IsAbstract), symbol.IsAbstract);
    Console.WriteLine(" {0}: {1}", nameof(symbol.IsStatic), symbol.IsStatic);

    // 継承しているクラスやインタフェースがあるかどうか
    if (syntax.BaseList != null)
    {
        // 継承しているクラスなどのシンボルを取得
        var inheritanceList = from baseSyntax in syntax.BaseList.Types
                                let symbolInfo = semanticModel.GetSymbolInfo(baseSyntax.Type)
                                let sym = symbolInfo.Symbol
                                select sym;

        // 継承しているクラスなどを出力
        Console.WriteLine(" Inheritance:");
        foreach (var inheritance in inheritanceList)
            Console.WriteLine("  {0}", inheritance.ToDisplayString());
    }
}
Public CodeAnalysisSample.Program
 Namespace: CodeAnalysisSample
 IsAbstract: False
 IsStatic: False
 Inheritance:
  CodeAnalysisSample.IProgram

BaseList.Typesまではすぐにたどり着くのですが、その後のセマンティックモデルから各Typeに対するSymbolInfoを取得し、その後シンボルを取得する必要があるのは初見でわかるわけないだろって感じです。
ただ、一応シンボルは取得できましたが、TypeDeclarationSyntax型では取得できなかったので基底クラスの基底クラスは取得できませんでした。

全文

おまけ

ドキュメント用内部IDの取得

foreach (var syntax in classSyntaxArray)
{
    var symbol = semanticModel.GetDeclaredSymbol(syntax);
    var id = symbol.GetDocumentationCommentId();
    Console.WriteLine(id);
}
T:CodeAnalysisSample.Program

ドキュメント用XMLの取得

foreach (var syntax in classSyntaxArray)
{
    var symbol = semanticModel.GetDeclaredSymbol(syntax);
    var xml = symbol.GetDocumentationCommentXml();
    Console.WriteLine(xml);
}
<member name="T:CodeAnalysisSample.Program">
    <summary>
    Program.
    </summary>
</member>

おわりに

とにかく情報が少ないので手探り状態でしたが、ひとまずここまで解析することができました。
初めはlexとyacc書かなきゃならんのかと気が重かったですが、公式でこのようなAPIが出てるのは非常にありがたいですね。
今回は触れていませんが、実行もできるのでこれは開発の幅が広がりそうです。