ヴィジュアルデザイナに public な Storongly Typed Resource クラスを生成させる。

GDNJ の #40746 のヤツ。

ビルドアクションにおけるカスタムツールは「ファイル以外に引数をとらない」という条件があります。 このため、.resx にアクセシビリティの項目を追加するなどで対応する必要がでてくるでしょう。
逆にいえば、常に public なカスタムツールを作るのは難しくないかもしれません。

を具体的に。
まず、カスタムツールの実装を始める。カスタムツールは IVsSingleFileGenerator というインターフェスを実装した COM オブジェクトまたは Managed class として実装する必要がある。ここでは C# を利用して Managed class を作成し、Managed Visual Studio Package 形式のモジュールを生成する。
このインターフェスには2つしかメソッドがないので、かなりサクサクと実装ができるだろう。エラーチェック等を省いた実装を簡単にみていくと、

int IVsSingleFileGenerator.DefaultExtension(out string defaultExtension)
{
  defaultExtension = ".resx";

  return VSConstants.S_OK;
}

まずは単純、デフォルトの拡張子を与える。ドキュメントに「先頭のピリオドは必須」と記載があることに注意するぐらい。

private enum GenerateStep
{
  ReadXml = 0,
  CreateCodeDOM,
  GenarateSource,
  WriteFile,

  Complete,
}

これは特に必要ないのだが、ビルド中のプログレスバーを動かす際の各ステップに名前を付ける目的と、ビルド手順が変更になったとき必要な全ステップ数を数えなおさなくてよいようにするためだけに用意してみた。
記述内容からわかるように、ソースコードの生成ではソースコードを文字列として作成するのではなく、いったん CodeDOM 形式で作成してから、各プログラム言語のコードプロバイダを利用してソースコードを生成する。
このようにすることで、

  • 知らないプログラム言語のソースコードも生成できる
  • 予約語などのエスケープ処理などの検査が必要なくなる
  • 標準ライブラリの実装が流用できる

といった利点が生まれる。

int IVsSingleFileGenerator.Genarate(
            [Obsolete] string inputFilePath,
            string inputFileContents,
            string defaultNamespace,
            IntPtr[] outputFileContents,
            out uint outputBytes,
            IVsGeneratorProgress generatorProgress)
{
  // 名前の無いドキュメント上に記載された定数
  const int AsWarning = 0;
  const int AsError = 1;
  const uint NONE = 0xFFFFFFFF;

  Dictionary<string, object> resources = new Dictionary<string, object>();

そしてこれが変換処理の実装本体となる部分だが、まずは .resx 形式になっている XML を読み込む処理。

  generatorProgress.GeneratorError((int) GenerateStep.ReadXml, (int) GenerateStep.Complete);

  // 渡された XML からリソースを読み込む
  using (ResXResourceReader reader = ResXResourceReader.FromContents(inputFileContents))
  {
    bool hasError = true;

    // エラー位置を取得するため、ResXNode 形式を要求する
    reader.UseResXDataNodes = true;
    foreach (DictionaryEntry de in reader)
    {
      ResXDataNode node = de.Value as ResXDataNode;

      try
      {
        resources[node.Name] = node.GetValue((ITypeResolutionService) null);
      }
      catch (Exception e)
      {
        hasError = true;

        // エラーを追加
        Point pos = node.GetNodePosition();
        generatorProgress.GeneratorError(AsError, 0, e.Message, (uint) (pos.Y - 1), (uint) (pos.X - 1));
      }
    }
  }

  // エラーがあったら終了する
  if (hasError)
  {
    return VSConstants.S_FALSE;
  }

あまり難しいことはしていない。.resx には任意の型がシリアライズできるのだが、インストールされていないコンポーネントをデシリアライズしようとしたりした場合、GetValue() が失敗するのでエラーとして報告するようになっている。GeneratorError() の LineNumber と ColumnNumber は 0-base であるのに対して、ResXResourceReader から得られる位置情報は 1-base のため 1 減算する必要がある。*1

  generatorProgress.GeneratorError((int) GenerateStep.CreateCodeDOM, (int) GenerateStep.Complete);

  // ソースコードの元となる CodeDOM を生成する。
  CodeDomProvider provider = CodeDomProvider.Create("CSharp");

  string typeName = StronglyTypedResourceBuilder.VerifyResourceName(
                          Path.GetFilenameWithoutExtension(inputFilePath),
                          provider);

  string[] unmatches;

  CodeCompileUnit unit = StronglyTypedResourceBuilder.Create(resources, typeName, defaultNamespace, provider, false, out unmatches);

  // 参照設定されていない
  foreach (string name in unmatches)
  {
    generatorProgress.generatorError(AsWarning, 0, 
            name + "は有効な識別子ではないため変換されませんでした。",
            NONE, NONE);
  }

ほとんどの重要な処理は、StronglyTypedResourceBuilder がやってくれるので、CodeDOM の使い方を知らない人でも安心である。ネームスペースはメソッドの引数で得られるのだが、生成するクラス名を得ることができないので Obsolete な引数から取得しているが、本来はターゲットとなる ProjectItem から取得するべきだろう。同様に、生成に使用している CodeDomProvider の言語を C# に固定しているので、この部分も Project 情報から取得する必要がある。これらについては(この文章を書く前のテスト実装で実装しなかったので)後でやりかただけ書いておく。
StronglyTypedResourceBuilder.Create() の引数 internalClass が false になっているため、生成されるコードは internal ではなく public で生成される。(これが、今回の目的を達成するための必須事項)

  using (MemoryStream ms = new MemoryStream())
  {
    generatorProgress.GeneratorError((int) GenerateStep.GenarateSource, (int) GenerateStep.Complete);

    // ソースコードを生成する。
    CodeCompileOptions opts = new CodeCompileOptions();
    using (TextWriter writer = new StreamWriter(ms, Encoding.UTF8))
    {
      provider.GenerateCodeFromCompileUnit(unit, writer, opts);
    }

説明なんぞ必要ない部分である。ドキュメントによると、出力ストリームは先頭に UTF-8 の BOM がある場合に限りテキストファイルとして検査され、そうでない場合はバイナリファイルとして扱われるそうなので、この時点で UTF-8ソースコードを出力しておく。

    generatorProgress.GeneratorError((int) GenerateStep.WriteFile, (int) GenerateStep.Complete);

    outputBytes = (uint) ms.Length;

    IntPtr outputBuffer = Marshal.AllocCoTaskMem((int) outputBytes);
    Marshal.Copy(ms.GetBuffer(), 0, outputBuffer, (int) outputBytes);

    outputFileContents = new IntPtr[] { outputBuffer };
  }

  return VSConstants.S_OK;
}

作成された内容を、そのまま出力バッファとして設定してカスタムツールとしての実装は完了する。ドキュメントに記載があるように、IntPtr に設定するメモリは Visual Studio が解放するために必ず Marshal.AllocCoTaskMem() を利用して割り当てなければならない。
少し考えなくてもわかることだが、このカスタムツールってヤツは非常にメモリを消費する。たとえば、100MB の ascii な .resx を処理して 150MB のソースコードを生成するとすると、inputFileContents は 200MB のメモリブロックになるし、生成される CodeDOM は小さなメモリ片を大量に保持した 200MB 近いオブジェクトツリーを生成し、生成されるソースコードを保持する MemoryStream は当然 150MB のメモリブロックとなり、AllocCoTaskMem() でも 150MB のメモリが確保される。
リソースファイル程度なら小さいものだろうけど、巨大な xsd を扱うような場合はカスタムツールに頼らずに SDK に付属のコマンドラインツールなどで事前生成するようにすると、Visual Studio 上での動作が快適になること間違いなし。

以上で、カスタムツールの作成が完了したわけだが、次なるステップとしてはこれを Visual Studio に登録して利用できるようにしなければならない。冒頭に記載したように、これを Visual Studio Package として完成させて登録するという手順が必要になる。
クラス宣言に対して Registration 属性の派生クラスをズラズラと並べる

[DefaultRegistryRoot(@"Software\microsoft\VisualStudi\8.0")]
[RegisterLoadKey(...)]
[ProvideGenerator(
  typeof(ResXFilePublicCodeGenerator),
  "ResXFilePublicCodeGenerator",
  "ResX to C# Code Generator",
  "...guid...",
  true)]
[Guid("...")]
public class ResXFilePublicCodeGenerator :

のように付与する。
DefaultRegistryRoot 属性はインストールされるときに登録先レジストリを省略された時に利用するレジストリキーで、Visual Studio の環境設定を丸ごと入れ替える機能と組み合わせて使う。たとえば、devenv.exe /rootsuffix mine として Visual Studio を起動すると、VisualStudio は上記の DefaultRegistryRoot の設定値に書かれたレジストリを参照するかわりに、指定したサフィックスを付けた @"Software\microsoft\VisualStudi\8.0mine" というレジストリを参照するようになる。これで IDE アドオンの開発環境と実行環境を一発切り替えしちゃえるというわけだ。
RegisterLoadKey 属性は必須の属性で、Visual Studio のセキュリティチェックを通すために必要となる。これは Microsoft とパートナープログラム契約を行い、専用の Web ページにおいて、作成したアドオンパッケージのプロダクト情報を記載して提出することで頂くことが出来る。*2*3 開発段階においては、開発ツールに入っている Developer Key を使用することで開発用マシンでのみ動作するようにできるので、不特定多数に対して公開するようなことがなく、すべての Visual Studio 環境で動作しなくてもよいのであれば、そういったキーを利用することもできるようになっている。
ProvideXXXX 属性は、この VisualStudio Package が提供するアドオン機能を列挙する。ここでは作成した CodeGenerator を1つだけ登録している。ProvideCodeGenerator 属性の最後の引数 generateDesignTimeSource が true となっているため、標準の ResXFileCodeGenerator と同様にコンパイル時ではなくデザイン時にソースコードが生成される。

上記の設定が完了したら、アセンブリをビルドしてパッケージツールを使って Visual Studio にアドオンし、リソースファイルのプロパティにて、カスタムツールの項目を ResXFileCodeGenerator から ResXFilePublicCodeGenerator に変更することで、このコード生成機能が働いて public なアクセシビリティをもった Storongly Typed Resource クラスが生成されるようになる。

最後に、途中でほったらかしにした部分だが、Generator の実装に IObjectWithSite という COM の標準インターフェスのようなものがあり、こいつを追加することでプロジェクトファイルやプロジェクトアイテムに対してアクセスができるようになる。このあたりは Visual Studio 2003 用のサンプルが山のようにあるので、そちらも参考にすると簡単ではないかと思われる。

ソースコードが自動生成できるってことは

id:yaneurao:20060113#p1 程度のソースコードフィルタなら、上記のような Geranator で実装してしまえば、IDE の上で主となるソースコードに変更するたびに、自動的にパーシャルファイルに実装が取り込まれて、ビルドすらしなくてもインテリセンスがビューンビューンなわけですよ。
というわけで、カスタムツール欄に YaneuraodeMixer とか記載するだけで勝手にソースコードカリカリ増えて、ビルドを押すだけできちんと合成済みのアセンブリが完成するようにする程度の実装にはしてもらわないと、ね?

*1:また、位置情報の取得に失敗すると 0, 0 を返すことになっており、-1 することで NONE 定数と一致するので少しありがたい。

*2:すべての手続きは英語です。

*3:私は契約はしていて、入力フォームまでは見たことあるんだけど、何も公開していないから実際に登録したことはない。