syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

Visual StudioのビルドイベントでPowerShellを踏み台にしてC#を使う

Visual Studioでプロジェクトをビルドする際に、複雑な前処理・後処理を記述する場合、通例バッチコマンドによるカスタマイズをします。ただ、Windowsのコマンドは貧弱で、Unix/Linux環境のシェルなどとは比べ物になりません。
従来のバッチコマンドの代わりにPowerShellを使うのが近代的なWindowsプログラマーですが、個人的にはPowerShellの文法が好きではありません。言語機能も従来のバッチコマンドと比べると遥かに柔軟かつ高機能ですが、我々の大好きなC#言語と比べるとかなり書きづらいです。できればPowerShellよりもF#スクリプトC#スクリプトを使いたいのですが、これらはOS機能として統合・標準化されていないのが難点です。

そこで、PowerShellからC#コンパイラを使い、C#ソースコード文字列を渡してコンパイルし、C#で書かれたクラスをPowerShellから利用するという方法をとってみます。

param($rootDir)

$assemblies = (
#"System" # 不要。
"Microsoft.CSharp" # dynamic 型を使用するために必要。
)

$source = @"
using System;
using System.Runtime.CompilerServices;
public static class Test
{
  public static void CheckLambdaSpec()
  {
    var data = new[] { 1, 2, 3, 4, 5 };
    Action a = null;
    foreach (var x in data)
    {
      a += () => Console.WriteLine(x);
    }
    a();
  }

  public static void DoCallerInfoTestImpl([CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
  {
    Console.WriteLine(string.Format("Member name = \"{0}\"", memberName));
    Console.WriteLine(string.Format("Source file path = \"{0}\"", sourceFilePath));
    Console.WriteLine(string.Format("Source line number = {0}", sourceLineNumber));
  }

  public static void DoCallerInfoTest()
  {
    DoCallerInfoTestImpl();
  }

  public static void DoTest(string dir)
  {
    //string path = System.IO.Path.Combine(System.IO.Path.Combine(dir, @"Properties"), @"AssemblyInfo.cs");
    //using (System.IO.StreamReader reader = new System.IO.StreamReader(path))
    var path = System.IO.Path.Combine(dir, @"Properties", @"AssemblyInfo.cs");
    using (var reader = new System.IO.StreamReader(path))
    {
      string line;
      //Console.WriteLine(line?.Length.ToString() ?? "null");
      dynamic x = "hoge";
      Console.WriteLine(x.Length);
      while ((line = reader.ReadLine()) != null)
      {
        if (line.StartsWith("[assembly: AssemblyVersion("))
        {
          Console.WriteLine(line);
          break;
        }
      }
    }
  }
}
"@

try
{
  # ここでは、プロジェクトの出力先がカレント ディレクトリになる模様。
  #[System.Environment]::CurrentDirectory
  #$PSVersionTable
  $rootDir
  $rootDir.Length
  # コンパイル時に "%LocalAppData%/Temp/" にテンポラリ ソースファイル (*.cs) が生成される模様。
  Add-Type -ReferencedAssemblies $assemblies -TypeDefinition $source -Language CSharp
  #Add-Type -TypeDefinition $source -Language CSharp
  [Test]::DoTest($rootDir)
  [Test]::DoCallerInfoTest()
  #[Test]::CheckLambdaSpec()
}
catch
{
  #Write-Error($_.Exception)
  Write-Error($_.Exception.Message)
  exit -1
}

上記PowerShellスクリプトをプロジェクトディレクトリに"test.ps1"として保存し、ビルド前あるいはビルド後イベントのコマンドラインとして、

powershell -ExecutionPolicy RemoteSigned -File "$(ProjectDir)test.ps1" "$(ProjectDir)\" ";exit $LASTEXITCODE"

を入力しておきます。
ここで、Visual Studio 環境変数PowerShell スクリプトコマンドライン引数として渡すとき、"$(ProjectDir)\" などとします。"$(ProjectDir)" ではダメなようです。末尾になぜか余計なダブルクォーテーションが入ります。

C#コンパイラのバージョン

前述のPowerShellからC#コンパイラを利用する手法自体に関してはWeb上のあちこちで言及されているのですが、そのC#コンパイラのバージョンに関してはほとんど言及がないようです。なので少し調べてみました。

前述の例はC# 5.0対応コンパイラが使えるという前提で記述しています。そもそもプリインストールされているPowerShellのバージョンもWindows OSによって異なるので、もしプロジェクトでPowerShellスクリプトを使う場合は最小バージョンに合わせて記述するか、開発者全員にPowerShellのバージョンアップを促しましょう。