C++ をclang で解析するときに情報をvcxproj から取得する方法
この記事はC++ Advent Calendar 2023 の5 日目です。 前に書いたものを簡単に流用しやすくなるように意識してまとめなおしたものです。
C++ でリフレクションやシリアライズを行うためにコードを解析したくなる場合があると思います。
その時の選択肢としてclang(libclang) やそのバインディングを利用するというのは以前から見かける方法です。
さらにWindows でのプログラミングの場合はlibclang に渡すためのインクルードパスなどの情報をVisual Studio のvcxproj から取得しようというアイディアも以前から存在しています。
例えば2017 年のヘキサドライブさんの記事(https://hexadrive.jp/hexablog/program/18139/) や2019 年のランカースさんの記事(http://www.lancarse.co.jp/blog/?p=2885) などです。
上記の記事ではvcxproj ファイルを独自に簡易的に解析して情報を抽出していました。目的によってはそれで十分ですが、Debug ビルドとRelease ビルドで設定が違う、といった場合など少し複雑な条件に対応しようとすると自前での解析はなかなか大変です。
Visual Studio でvcxproj を実際に解析して処理しているのは内部で使用されているMSBuild です。MSBuild の機能はMSBuild API としてC# から利用できるパッケージがNuGet で提供されています。vcxproj の処理はMSBuild API に任せてしまえば後はlibclang でパースしたコードの解析に専念することができます。
以下、C# やNuGet パッケージ、MSBuild の話になりますがそれぞれの細かい説明は省きます。
カスタムタスク
MSBuild が解析したvcxproj の情報を取得するには、C# でカスタムタスクというものを作成してvcxproj に組み込みます。
カスタムタスクは、NuGet で取得できるMicrosoft.Build パッケージ、Microsoft.Build.Utilities.Core パッケージに含まれるMicrosoft.Build.Utilities.Task クラスを継承したクラスがその本体です。例えば以下のようなものです。
using Microsoft.Build.Utilities; namespace MyCustomTask { public class MyCustomTask : Task { // ソースコードファイル名 public string SourceFiles { get; set; } // インクルードパス public string IncludePaths { get; set; } // プリプロセッサマクロ定義 public string PreprocessorMacro { get; set; } // 実際の処理 public override bool Execute() { // 解析処理 // 成功したらtrue を返す return true; } } }
プロパティはMSBuild のプロジェクトとパラメータを受け渡すのに使用します。
Execute はカスタムタスクの処理を記述するメソッドでMSBuild から呼び出されます。
これをC# のクラスライブラリプロジェクトとしてビルドしてdll ファイルを生成します。
vcxproj への組み込み
カスタムタスクとはカスタムしたMSBuild のタスクですが、タスクというのはMSBuild が処理するプロジェクト(vcxproj やcsproj) に含まれる、実際の処理の単位です。
MSBuild のタスクはプロジェクトファイルの中にあるターゲットに含まれます。ターゲットというのはタスクをまとめたもので、処理の起点として指定したり、処理同士の依存関係や処理順序を制御する役割があります。既定のターゲットとしてBuild やClean などがあります。
作成したカスタムタスクは、MSBuild プロジェクトにUsingTask 要素で追加して、独自ターゲットから呼び出します。MSBuild プロジェクトへの追加は、vcxproj ファイルを直接編集することもできますが、vcxproj と同じディレクトリにDirectory.Build.props やDirectory.Build.targets などのファイルを置いておくと自動的にインポートされます。これらはvcxproj と同様のxml 形式のファイルですが、Directory.Build.props はvcxproj の内容より前にインポートされて、Directory.Build.targets はvcxproj の内容より後にインポートされるという違いがあります。次のコードはDirectory.Build.targets の例です。
<Project> <!-- vcxproj と同じディレクトリにあるMyCustomTask.dll のタスクを使用 --> <UsingTask TaskName="MyCustomTask.MyCustomTask" AssemblyFile="MyCustomTask.dll" /> <!-- 独自ターゲット --> <Target Name="MyCustomTarget" BeforeTargets="ClCompile"> <!-- カスタムタスクを呼び出す --> <MyCustomTask SourceFiles="@(ClCompile)" IncludePaths="$(IncludePath);%(ClCompile.AdditionalIncludeDirectories)" PreprocessorMacro="%(ClCompile.PreprocessorDefinitions)" /> </Target> </Project>
UsingTask で使用するタスクの名前とタスクが含まれるアセンブリを指定しています。タスク名は名前空間も含めたものです。Target の子要素として呼び出すときは名前が衝突しなければ名前空間は省略できます。
Target 要素ではName 属性で任意の名前を指定しています。BeforeTargets 属性は、このMyCustomTarget がどのターゲットより前に処理されるかという依存関係を表します。指定されているClCompile ターゲットは、MSBuild でのC++ のコンパイルを行うターゲットです。つまりこの例はMyCustomTarget ターゲットに含めたMyCustomTask がC++ のコンパイルの前に呼び出されるような指定となります。
カスタムタスククラスへの入力
カスタムタスククラスのプロパティにパラメータを渡す場合、MSBuild プロジェクトではタスク要素の属性として指定します。前のDirectory.Build.targets の例の場合を順に説明します。
まず入力ソースファイルを受け取るプロパティのSourceFiles には@(ClCompile) を渡していますが、これはvcxproj の中を覗くと見つけられるItemGroup の子要素となっているClCompile をまとめたものです。MSBuild の用語ではItemGroup の子要素は項目といいます。@(ClCompile) はカスタムタスクのC# 側クラスのプロパティにはセミコロンで連結されたファイルパスとして文字列で渡されます。C# 側ではそれを分割すればファイルパスリストに戻せるというわけです。
また、MSBuild 側で@(ClCompile) の代わりに%(ClCompile.Identity) を指定すると、ファイルパスひとつずつに対してカスタムタスクが実行されます。この場合C# 側が受け取るのは単一のファイルパスになります。
インクルードパスを受け取るIncludePaths には$(IncludePath) と%(ClCompile.AdditionalIncludeDirectories) をセミコロンで連結して渡しています。$(IncludePath) はプロジェクト全体に指定されたインクルードパスを保持するプロパティです。SDK ごとのパスなども含まれています。
%(ClCompile.AdditionalIncludeDirectories) は追加のインクルードパスを保持するメタデータです。Visual Studio IDE でプロジェクトのプロパティから指定できる 追加のインクルードディレクトリが入力されますが、ソースファイル個別のプロパティで追加のインクルードディレクトリが上書きされている場合はそちらに置き換わります。C# 側への入力がどうなるかというと、同じ値を持つClCompile 要素ごとに分けて呼び出されます。これは項目のメタデータのバッチ処理という機能によるものです。
最後にプリプロセッサマクロ定義を受け取るPreprocessorMacro には%(ClCompile.PreprocessorDefinitions) が渡されています。これもメタデータで、%(ClCompile.AdditionalIncludeDirectories) と同様に処理されます。
なお、目的の情報がMSBuild 内の項目、メタデータ、プロパティなどのどこから取得できるのかはvcxproj をMSBuild でプリプロセスし、インポートされるプロジェクトがすべて展開された状態のファイルを出力して、その中から探しました。ほかに必要な情報がある場合も同様に調べることが可能です。
これで、カスタムタスククラスにソースコードのファイルパスと必要な情報を渡すことができるようになりました。解析はlibclang なりClangSharp なり、お好みのライブラリ、言語で自由にやっちゃってください。
生成したソースコードをビルドに含める
なお、カスタムクラスでC++ ソースコードを生成した場合、それをそのままビルドに含めることもできます。
using Microsoft.Build.Utilities; namespace MyCustomTask { public class MyCustomTask : Task { // 略 // MSBuild に値を返すプロパティ [Output] public string GeneratedFiles { get; set; } // 実際の処理 public override bool Execute() { // 解析処理は省略 GeneratedFiles = "path/to/generated.cpp"; return true; } } }
<Project> <!-- 略 --> <Target Name="MyCustomTarget" BeforeTargets="ClCompile"> <!-- カスタムタスクを呼び出す --> <MyCustomTask SourceFiles="@(ClCompile)" IncludePaths="$(IncludePath);%(ClCompile.AdditionalIncludeDirectories)" PreprocessorMacro="%(ClCompile.PreprocessorDefinitions)"> <!-- 出力プロパティの値をClCompile 項目に追加 --> <Output TaskParameter="GeneratedFiles" ItemName="ClCompile" /> </MyCustomTask> </Target> </Project>
C# 側のクラスでOutput 属性を持つプロパティに持たせた値を、MSBuild プロジェクト側ではタスク要素の子のOutput 要素で追加先としてItemName にClCompile を指定するだけです。