.NETでRoslynを使った構文解析とセマンティック解析をする方法についてまとめました。
はじめに
この記事では、Roslyn API(正式名称は.NET Compiler Platform SDK)を使って構文解析とセマンティック解析をする方法についてまとめます。
こちらに書いてある通り、Roslyn関連のパッケージはいくつかあります。
このうち本記事では Microsoft.CodeAnalysis.CSharp を使って古文解析とセマンティック解析を行います。
Workspaces系のパッケージを使うとソリューションをコンパイルしたりできますが、今回はそこまではやりません。
セットアップ
準備として、.NETのコンソールアプリケーションプロジェクトを作成し、 NuGetから Microsoft.CodeAnalysis.CSharp パッケージをインストールしておきます。
構文解析
ノード・トークン・トリビア
Roslynでは、プログラムの構文解析結果をノードとトークンとトリビアという3つの要素とツリー構造により表現するので、まずこれらを理解します。
例として以下のようなプログラムの構文解析を行います。
using System; // test class Program { static void Main(string[] args) { Console.WriteLine("Hello, World!"); } }
以下は上記のプログラムの文字列から、簡単な構文解析を行う例です。
using Microsoft.CodeAnalysis.CSharp; // 解析対象の文字列 const string programText = """ using System; // test class Program { static void Main(string[] args) { Console.WriteLine("Hello, World!"); } } """; // プログラムの文字列から構文ツリーを生成する var tree = CSharpSyntaxTree.ParseText(programText); // ルートのSyntaxNodeを取得する var root = tree.GetRoot(); // ルートの子ノードを取得してその種類を出力 foreach (var child in root.ChildNodes()) { Console.WriteLine($"{child.Kind()}"); }
コメントの通り、CSharpSyntaxTree.ParseText
で構文ツリーを作成し、GetRoot
メソッドでルートノードを取得しています。
さらに子ノードを取得して、その種類をログ出力しています。出力果は以下の通りです。
UsingDirective ClassDeclaration
このプログラムは一つのUsingと一つのクラスから構成されていることがわかりました。
クラスの中身を解析していくには、クラスを表すノードの子ノードをさらに取得していきます。
ノードの文字列は以下のようにToFullString
により取得できます。
var tree = CSharpSyntaxTree.ParseText(programText); var root = tree.GetRoot(); // 最初のノードの種類とフル文字列を取得 var firstNode = root.ChildNodes().First(); Console.WriteLine($"{firstNode.Kind()}: {firstNode.ToFullString()}");
出力結果は以下の通りです。
UsingDirective: using System; // test
さて、次にこのUsingを表すノードをもう少し細かく解析します。
これを行うにはノードの子ノードあるいはトークンを取得します。
using Microsoft.CodeAnalysis.CSharp; const string programText = """ using System; // test (省略) """; var tree = CSharpSyntaxTree.ParseText(programText); var root = tree.GetRoot(); var firstNode = root.ChildNodes().First(); // 子ノードあるいはトークンを取得して情報を出力 foreach (var child in firstNode.ChildNodesAndTokens()) { var prefix = child.IsNode ? "Node" : "Token"; Console.WriteLine($"[{prefix}] {child.Kind()}: {child.ToFullString()}"); }
これの実行結果は以下の通りです。
[Token] UsingKeyword: using [Node] IdentifierName: System [Token] SemicolonToken: ; // test
これでUsingノードの構成がわかりました。
またこのusingやセミコロンのように、キーワードや識別し、リテラル、句読点はノードではなくトークンと呼ばれ、子を持ちません。
さらに、セミコロントークンの後ろにはコメントが付いていることがわかります。
このような構文に関係ないもの(コメント、空白など)はトリビアと呼ばれ、以下のようにして取得できます。
using Microsoft.CodeAnalysis.CSharp; const string programText = """ using System; // test (省略) """; var tree = CSharpSyntaxTree.ParseText(programText); var root = tree.GetRoot(); var firstNode = root.ChildNodes().First(); foreach (var child in firstNode.ChildNodesAndTokens()) { Console.WriteLine($"{child.Kind()}: {child.GetTrailingTrivia()}"); }
実行結果は以下の通りです。
UsingKeyword: IdentifierName: SemicolonToken: // test
Nodeクラスの派生型
上記のノードはSyntaxNode
クラスを使って表現されますが、例えばusingディレクティブであれば実際にはUsingDirectiveSyntax
のように、その派生クラスのインスタンスが使われます。
GetRoot
の代わりにGetCompilationUnitRoot
を使うと、これらの派生クラスの型を使った解析が以下のように行えます。
using Microsoft.CodeAnalysis.CSharp; const string programText = """ using System; // test (省略) """; var tree = CSharpSyntaxTree.ParseText(programText); // GetRootの代わりにGetCompilationUnitRootを使う var root = tree.GetCompilationUnitRoot(); // 全てのUsingが取得できる var usingDirectives = root.Usings; foreach (var usingDirective in usingDirectives) { Console.WriteLine($"{usingDirective.Kind()}: {usingDirective.ToFullString()}"); }
ノードに派生型が定義されていることを利用して、以下のようにプログラム内の全てのメソッドを取得したりすることもできます。
using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; const string programText = """ using System; // test (省略) """; var tree = CSharpSyntaxTree.ParseText(programText); var root = tree.GetCompilationUnitRoot(); // 子孫含め全てのメソッドを取得 var methodDeclarations = root.DescendantNodes().OfType<MethodDeclarationSyntax>(); foreach (var methodDeclaration in methodDeclarations) { Console.WriteLine($"{methodDeclaration.Kind()}: {methodDeclaration.Identifier}"); }
出力結果は以下の通りです。
MethodDeclaration: Main
構文ウォーカー
ここまでは構文ツリーを走査することで構文解析を行ってきました。
これとは別に、構文ツリーの解析に便利な構文ウォーカーというクラスが用意されています。
これを使うと以下のように、ツリー全体を走査した結果、特定の型のノードやトークンにたどり着いた時にだけ行う処理を書くことができます。
using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; const string programText = """ using System; using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; namespace TopLevel { using Microsoft; using System.ComponentModel; }"; """; var tree = CSharpSyntaxTree.ParseText(programText); var root = tree.GetCompilationUnitRoot(); var collector = new MicrosoftUsingCollector(); collector.Visit(root); foreach (var directive in collector.Usings) Console.WriteLine(directive.Name); // Microsoftの名前空間のusingだけを集める構文ウォーカー internal class MicrosoftUsingCollector : CSharpSyntaxWalker { public ICollection<UsingDirectiveSyntax> Usings { get; } = new List<UsingDirectiveSyntax>(); // UsingDirectiveSyntaxにたどり着いた時に呼ばれる処理 public override void VisitUsingDirective(UsingDirectiveSyntax node) { // Microsoftで始まる名前空間のみを取得 var nodeName = node.Name.ToString(); if (!nodeName.StartsWith("Microsoft")) return; Usings.Add(node); } }
これの実行結果は以下の通りです。
Microsoft.CodeAnalysis Microsoft.CodeAnalysis.CSharp Microsoft
SyntaxTreeを可視化する
SyntaxTreeを可視化する方法として、Visual StudioにはSyntax Visualizerというツールが用意されています。
また、ShapLabでも入力したコードのSyntaxTreeを見ることができます。
セマンティック解析
セマンティック解析とは
構文解析では、プログラムの文法を解析することができました。
これに対してセマンティック解析は、「例えば全然別の2箇所で呼ばれている同名のメソッドが同一のメソッドを表しているか」など、プログラムの意味を踏まえた解析をすることができます。
セマンティック解析では、まず関連するプログラム・アセンブリを集めて「コンパイル」作成します。
コンパイルには型やフィールド、メソッド、ローカル変数といった情報が「シンボル」として保持されているので、これを使って解析していきます。
メソッドの呼び出し元を取得する
それでは実際にセマンティック解析を行って、指定したメソッドの呼び出し元を取得してみます。
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; const string programText = """ using System; namespace HelloWorld { class Example { static void Test() { Console.WriteLine("Hello, World!"); } } } """; // コンパイルを作成 var tree = CSharpSyntaxTree.ParseText(programText); var compilation = CSharpCompilation.Create("Example") // 任意のアセンブリ名をつけておく .AddReferences( // System.Console.WriteLine(string) が含まれるアセンブリを追加 MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), // string が含まれるアセンブリを追加(Console.WriteLineがstringを引数にとるため必要) MetadataReference.CreateFromFile(typeof(string).Assembly.Location)) .AddSyntaxTrees(tree); // 上記の文字列のプログラム(の構文ツリー)を追加 // セマンティックモデルをコンパイルから取得 var semanticModel = compilation.GetSemanticModel(tree); // Console.WriteLineメソッドのシンボルを取得 var writeLineSymbol = compilation.GetTypeByMetadataName("System.Console")! .GetMembers("WriteLine") .OfType<IMethodSymbol>() // Console.WriteLineは複数のオーバーロードを持つので、引数が1つでその1つがstring型のものを取得 .First(m => m.Parameters.Length == 1 && m.Parameters[0].Type.MetadataName == "String"); // 構文ツリーから全てのメソッド呼び出しノードを取得 var invocations = tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>(); foreach (var invocation in invocations) { // 呼び出されているメソッドのシンボルを取得して、それがConsole.WriteLineだったら処理 // なお何かしらの理由でシンボルが解決されなかった場合はGetSymbolInfo()のCandidateReasonやCandidateSymbolsを見ると良い var invokedSymbol = (IMethodSymbol)semanticModel.GetSymbolInfo(invocation).Symbol!; if (SymbolEqualityComparer.Default.Equals(invokedSymbol, writeLineSymbol)) { // このメソッド呼び出しを行っているメソッドの名前を取得 // (メソッド呼び出しノードの祖先からメソッド定義ノードを取得して、一番近いメソッド名を取得) var methodName = invocation.Ancestors().OfType<MethodDeclarationSyntax>().First().Identifier.ToString(); Console.WriteLine($"Method name: {methodName}"); } }
少々複雑ですが、コメントとして記述した説明を追っていけば意味がわかると思います。
ちなみに今回は不要ですが、以下のようにするとコンパイルエラーを検知できたりします。
foreach (var diagnostic in compilation.GetDiagnostics()) { Console.WriteLine(diagnostic); }