こんにちは、キャスレーコンサルティングのSI(システム・インテグレーション)部の中尾です。

初めに

MicrosoftがC# 6よりC#のコンパイラをC#で作成しました。このC#コンパイラを“Roslyn”と呼びます。

以降Visual StudioはコンパイラとしてこのRoslynを使用する様になりましたが、合わせてRoslynのコンパイラの機能を利用するAPIをライブラリとして公開してくれたため、開発者はC#プログラム側からC#ソースをコンパイルしたり構造解析を行ったりするなどのソースコードの構文に関する処理が出来るようになりました。

前回の記事「Roslyn for Scripting – C#プログラム内でC#で書かれたスクリプトを実行しよう」ではその中のRoslyn for ScriptingというC#をスクリプト実行できる機能を紹介いたしました。

今回は、RoslynのAPIを使って、C#のソースコードの構造解析の方法を紹介したいと思います。

環境作成方法

開発環境は、MicrosoftのサイトからVisual Studio Communityをダウンロードし、インストールすれば完了です。

ただし、インストールの際にオプションで[Windows 開発と Web 開発]→[Windows 8.1 および Windows Phone 8.0/8.1 ツール]→[ツールと Windows SDK]および[共通ツール]→[Visual Studio 拡張性ツール]にチェックを入れるのを忘れないで下さい。
※環境によって文言が異なる場合があります。

CodeAnalysis01

インストールが完了しましたらVisualStudioを起動してプロジェクトを作成した後、作成したプロジェクトに対してメニューの[ツール]→[NuGet パッケージマネージャー]→[ソリューションのNuGetパッケージの管理]より[Microsoft.CodeAnalysis]をインストールしてください。

CodeAnalysis02

実装サンプル

構文解析の機能は、Microsoft.CodeAnalysis名前空間のSyntaxWalkerクラスにて提供されており、必要なメソッドをオーバーライドする事で機能を利用する事が出来ます。今回はトークンに対する処理を行うVisitTokenメソッドと、トークンのトリビア情報に対する処理を行うVisitTriviaメソッドを利用してみます。

CaslaySampleSyntaxWalker.cs

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

namespace CaslaySample
{
    public class CaslaySampleSyntaxWalker : SyntaxWalker
    {
        private SemanticModel semanticModel;

        /// <summary>
        /// コードの解析を行い、builderに記録します
        /// </summary>
        public void Analyze(string code)
        {
            // ①単体のソースコードを構文解析します。
            var tree = CSharpSyntaxTree.ParseText(code);

            // 解析結果から診断を取得
            int analyzeErrorNo = 0;
            foreach (var item in tree.GetDiagnostics())
            {
                analyzeErrorNo++;
                Console.WriteLine($"構文エラー番号{analyzeErrorNo}");
                Console.WriteLine(item.Severity.ToString());
                Console.WriteLine(item.GetMessage());
                Console.WriteLine(item.Id);
                Console.WriteLine(item.Location.SourceSpan.ToString());
                Console.WriteLine("");
            }
            if (analyzeErrorNo == 0)
            {
                Console.WriteLine($"構文エラーは存在しません");
            }

            // ③ソースコードをコンパイルします。
            var compilation = CSharpCompilation.Create("sample",
                syntaxTrees: new[] { tree },
                references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) });

            // セマンティックモデル(構文木を走査してNameSyntaxなどの意味を解釈したもの)を取得
            semanticModel = compilation.GetSemanticModel(tree);

            // コンパイル結果から診断を取得
            int compileErrorNo = 0;
            foreach (var item in compilation.GetDiagnostics())
            {
                compileErrorNo++;
                Console.WriteLine($"コンパイルエラー番号{compileErrorNo}");
                Console.WriteLine(item.Severity.ToString());
                Console.WriteLine(item.GetMessage());
                Console.WriteLine(item.Id);
                Console.WriteLine(item.Location.SourceSpan.ToString());
                Console.WriteLine("");
            }
            if (compileErrorNo == 0)
            {
                Console.WriteLine($"コンパイルエラーは存在しません");
            }

            // 各トークンを見ていく
            foreach (var token in tree.GetRoot().DescendantTokens())
            {
                this.VisitToken(token);
            }
        }

        /// <summary>
        /// ④VisitTokenメソッドによるトークンに対する処理
        /// </summary>
        /// <param name="token"></param>
        protected override void VisitToken(SyntaxToken token)
        {
            // 詳細を持っているトークンである場合
            if (token.HasLeadingTrivia)
            {
                foreach (var trivia in token.LeadingTrivia)
                {
                    VisitTrivia(trivia);
                }
            }

            bool isProcessed = false;

            // キーワードであるか
            if (token.IsKeyword())
            {
                Console.WriteLine($"{"Keyword",-30}:{token.ValueText}");
                isProcessed = true;

            }
            else
            {
                Console.WriteLine($"{token.Kind(),-30}:{token.ValueText}");
                isProcessed = true;
            }

            // それ以外の項目
            if (!isProcessed)
            {
                Console.WriteLine($"{"Etc",-30}:{token.ValueText}");
            }

            if (token.HasTrailingTrivia)
            {
                foreach (var trivia in token.TrailingTrivia)
                {
                    VisitTrivia(trivia);
                }
            }

        }

        /// <summary>
        /// ⑤VisitTriviaメソッドによるトークンのトリビアに対する処理
        /// </summary>
        /// <param name="trivia"></param>
        protected override void VisitTrivia(SyntaxTrivia trivia)
        {
            // 空白と改行の場合は無視
            if (trivia.Kind() == SyntaxKind.WhitespaceTrivia || trivia.Kind() == SyntaxKind.EndOfLineTrivia)
            {
                return;
            }

            Console.WriteLine($"{trivia.Kind(),-30}:{trivia.ToFullString()}");
            base.VisitTrivia(trivia);
        }

    }
}

①単体のソースコードを構文解析します。
CSharpSyntaxTree.ParseTextメソッドの引数にソースコードの文字列を渡す事で、他のソースコードやdllとのリンクなどは考えない、純粋にC#のプログラム言語の構文を解析し、解析結果を木構造のオブジェクトとして返します。

②コンパイルを行います。
①の構文解析を行った結果を入力として、コンパイルを行います。
CSharpCompilation.Createの引数に①の構文解析結果と、引数referencesとして、どのような環境でコンパイルを行うかを指定します。
サンプルでは、今作っているこのプログラムと同じコンパイル環境にてコンパイルを行っています。

③ソースコードをコンパイルします。
CSharpSyntaxTree.ParseTextメソッドの引数にソースコードの文字列を渡す事で、他のソースコードやdllとのリンクなどは考えない、純粋にC#のプログラム言語の構文を解析し、解析結果を木構造のオブジェクトとして返します。

④VisitTokenメソッドによるトークンに対する処理
構文解析を行った結果が引数のtokenに格納されている為、その結果をコンソールに出力しています。

IsKeywordメソッドでキーワードかどうかを判断し、キーワードで無い場合はKindメソッドで種類を判断します。

メソッドの一番最初と一番最後にあるif文の「token.HasLeadingTrivia」「token.HasTrailingTrivia」ですが、これはトークンの前および後ろに付随するコメントや空白などのロジックとは直接関係が無いトリビア情報の有無になります。戻り値が真で存在する場合は、VisitTriviaメソッドに渡してトリビア情報を表示します。

⑤VisitTriviaメソッドによるトークンのトリビアに対する処理
トークンに付随するトリビア情報をコンソールに出力しています。
Kindメソッドで種類を判断し、空白と改行以外を種類と共に出力しています。

作成したCaslaySampleSyntaxWalkerを使って構文解析を行うプログラムは以下のものとなります。

Program.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace CaslaySample
{
    class Program
    {
        static void Main(string[] args)
        {
            #region 構文確認対象のソースコード
            String analysisTargetCode = @"
using System;

namespace CaslaySample
{
    public class AnalysisSampleCsCode
    {
        private static String MESSAGE_FORMAT = ""{0} を 2倍した値は {1} です。"" // セミコロンが無い

        #region サンプルメソッド
        /// <summary>
        /// パラメータに整数を渡し、2倍した値をコンソールに出力する
        /// </summary>
        /// <param name=""args"">パラメータ</param>
        static void Main(string[] args)
        {
            Int value = Int32.Parse(args[0]);  // Intと言う型は存在しない
            Console.WriteLine(String.Format(AnalysisSampleCsCode.MESSAGE_FORMAT, value, value * 2));
        }
        #endregion
    }
}
";
            #endregion

            CaslaySampleSyntaxWalker syntaxWalker = new CaslaySampleSyntaxWalker();
            syntaxWalker.Analyze(analysisTargetCode);
        }
    }
}

単純に、CaslaySampleSyntaxWalker@Analyzeメソッドに構文解析対象のソースコードを文字列で渡してあげます。

実行結果は以下の通りになります。

構文エラー番号1
Error
; expected
CS1002
[161..161)

コンパイルエラー番号1
Error
; expected
CS1002
[161..161)

コンパイルエラー番号2
Error
The type or namespace name 'Int' could not be found (are you missing a using directive or an assembly reference?)
CS0246
[402..405)

Keyword                       :using
IdentifierToken               :System
SemicolonToken                :;
Keyword                       :namespace
IdentifierToken               :CaslaySample
OpenBraceToken                :{
Keyword                       :public
Keyword                       :class
IdentifierToken               :AnalysisSampleCsCode
OpenBraceToken                :{
Keyword                       :private
Keyword                       :static
IdentifierToken               :String
IdentifierToken               :MESSAGE_FORMAT
EqualsToken                   :=
StringLiteralToken            :{0} を 2倍した値は {1} です。
SingleLineCommentTrivia       :// セミコロンが無い
SemicolonToken                :
RegionDirectiveTrivia         :#region サンプルメソッド

SingleLineDocumentationCommentTrivia:/// <summary>
        /// パラメータに整数を渡し、2倍した値をコンソールに出力する
        /// </summary>
        /// <param name="args">パラメータ</param>

Keyword                       :static
Keyword                       :void
IdentifierToken               :Main
OpenParenToken                :(
Keyword                       :string
OpenBracketToken              :[
OmittedArraySizeExpressionToken:
CloseBracketToken             :]
IdentifierToken               :args
CloseParenToken               :)
OpenBraceToken                :{
IdentifierToken               :Int
IdentifierToken               :value
EqualsToken                   :=
IdentifierToken               :Int32
DotToken                      :.
IdentifierToken               :Parse
OpenParenToken                :(
IdentifierToken               :args
OpenBracketToken              :[
NumericLiteralToken           :0
CloseBracketToken             :]
CloseParenToken               :)
SemicolonToken                :;
SingleLineCommentTrivia       :// Intと言う型は存在しない
IdentifierToken               :Console
DotToken                      :.
IdentifierToken               :WriteLine
OpenParenToken                :(
IdentifierToken               :String
DotToken                      :.
IdentifierToken               :Format
OpenParenToken                :(
IdentifierToken               :AnalysisSampleCsCode
DotToken                      :.
IdentifierToken               :MESSAGE_FORMAT
CommaToken                    :,
IdentifierToken               :value
CommaToken                    :,
IdentifierToken               :value
AsteriskToken                 :*
NumericLiteralToken           :2
CloseParenToken               :)
CloseParenToken               :)
SemicolonToken                :;
CloseBraceToken               :}
EndRegionDirectiveTrivia      :#endregion

CloseBraceToken               :}
CloseBraceToken               :}
EndOfFileToken                :

まず最初に「①単体のソースコードを構文解析します。」による構文エラーが出力されます。セミコロンが無いエラーが構文エラーになりますが、クラス名がIntとなっているエラーに関しては、コンパイル時にリンク先にそのようなクラスがあるなど、単体ではエラーと判断することは出来ないので、エラーにはなりません。

続いて「③ソースコードをコンパイルします。」によるコンパイルエラーが出力されます。ここでは先程の構文エラーでは判断出来なかったクラス名がIntとなっているエラーが出力されます。

最後は、ソースコードの構文解析をトークンとして取得し、トークンの内容を一覧化して出力しています。Kindメソッドの戻り値としてそのトークンの種類が分かるので、これを利用して独自にチェック機能などを作れることがわかると思います。

終わりに

この様に、提供されたRoslynのAPIを利用することで、簡単に構文解析を行う事が出来ます。今後、JavaのEclipseプラグインのCheckStyleの様な記述チェック機構などがこの機能を利用して発展するかもしれませんね。