PCL プロジェクトを .NET Standard 2.0 プロジェクトに変換する Visual Studio 拡張機能を作った話

## はじめに

本記事は、[Xamarin Advent Calendar 2018](https://qiita.com/advent-calendar/2018/xamarin) の 23日目の記事です。今年も誕生日の節目に記事を書くことができました。ありがとうございます。

本記事は、PCL プロジェクトを .NET Standard のプロジェクトに変換するための Visual Studio の拡張機能（[PCL TO .NET Standard](https://marketplace.visualstudio.com/items?itemName=tafuji.pcl2netstandard)）を作成したときの技術的なポイントを記載したメモです。

## なぜ拡張機能を作ったのか？

Xamarin で、PCL を使って実装されていたプロジェクトを .NET Standard 対応させるときに、手動でプロジェクトファイルを書き換えるのが面倒だったので作成しました。
Visual Studio for Mac には、[Mutatio](https://github.com/yuv4ik/Mutatio) というツールがあるのですが、Visual Studio 2017 に対応したツールがなかっことも理由の一つです。

## 拡張機能の概要

PCL プロジェクトを右クリックして、[Convert to .NET Standard] を選択すると、PCL プロジェクトが .NET Standard プロジェクトに変換されます。

![Demo](https://gist.githubusercontent.com/tafuji/bcc70dbf174df5bc1d780a038772688b/raw/69c6c4c134a28b1200be2c735358dedc662bdba3/01-pcl-to-netstandard-demo.gif)

具体的には、以下の処理を行っています。

- PCL プロジェクト関係のファイルをバックアップフォルダにコピーする
    - プロジェクトファイル、AssemblyInfo.cs ファイル、package.config ファイルなど
- .NET Standard 2.0 のプロジェクトファイルを作成する
- PCL プロジェクトが参照していた NuGet パッケージ、プロジェクト参照を .NET Standard 2.0 のプロジェクトの参照に追加する
- 変換元の PCL プロジェクト関係のファイルを削除する
    - プロジェクトファイル、AssemblyInfo.cs ファイル、package.config ファイルなど
- プロジェクトをリロードする

## 技術的なメモ

拡張機能を作成したきのポイントを記載しておきます。

### VSCT ファイル

Visual Studio Command Table ファイルにメニューコマンドの配置に関する設定を記述します。
まず、コマンドメニューのグループとそのコマンドを定義しています。

```xml
<!-- This is the guid used to group the menu commands together -->
<GuidSymbol name="guidConvertCommandPackageCmdSet" value="{6553808a-4ee1-4e84-b99c-02f13a2e3246}">
    <IDSymbol name="MyMenuGroup" value="0x1020" />
    <IDSymbol name="ConvertCommandId" value="0x0100" />
</GuidSymbol>
```

### コマンドを Visual Studio のメニューのどこに配置するかの設定

```MyMenuGroup``` というコマンドグループがどこに配置されるかを以下のように設定しています。

```xml
<CommandPlacements>
    <CommandPlacement guid="guidConvertCommandPackageCmdSet" id="MyMenuGroup" priority="0xFFFF">
        <Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_PROJNODE"/>
    </CommandPlacement>
</CommandPlacements>
```

ポイントは、```Prant``` 要素で、コマンドグループをどこに配置するかを指定します。```guidSHLMainMenu``` は、Visual Studio のメニューバーの GUID 値で、```id``` の ```IDM_VS_CTXT_PROJNODE``` という値は、プロジェクトのコンテキストメニューを表します。つまり、拡張機能のメニューコマンドは、Visual Studio のプロジェクトのコンテキストメニューに配置するという意味になります。

### メニューの項目の表示・非表示を切り替える

右クリックしたプロジェクトが PCL プロジェクトではないときには、メニューを非表示にしたいので、```CommandFlag``` 要素の設定で、表示を動的にする設定値とデフォルトでは非表示とする設定を追加しておきます。

```xml
<Button guid="guidConvertCommandPackageCmdSet" id="ConvertCommandId" priority="0x0100" type="Button">
    <Parent guid="guidConvertCommandPackageCmdSet" id="MyMenuGroup" />
    <Icon guid="commandImage" id="icon1" />
    <CommandFlag>DynamicVisibility</CommandFlag>
    <CommandFlag>DefaultInvisible</CommandFlag>
    <Strings>
        <ButtonText>Convert to .NET Standard</ButtonText>
    </Strings>
</Button>
```

### Command クラス

Visual Studio の項目テンプレートから作成したものに追記をしたものについて記載します。

#### IVsPackageInstallerServices のサービスの取得

PCL プロジェクトにインストールされている NuGet パッケージを取得するために、```NuGet.VisualStudio``` という NuGet パッケージを利用します。
このパッケージ内の ```IVsPackageInstallerServices``` インタフェースを利用する必要があるため、Command クラスの中でサービスを取得しておきます。

プロジェクトテンプレートで作成された ```InitializeAsync``` メソッドの中で、```AsyncPackage``` クラスの ```GetServiceAsync``` メソッドを利用して取得しておきます。

```csharp
public static async Task InitializeAsync(AsyncPackage package)
{
    // ......
    var componentModel = await package.GetServiceAsync(typeof(SComponentModel)) as IComponentModel;
    // ......
}
```

#### BeforeQueryStatus イベントの登録

メニューを表示する前に呼び出されるイベントハンドラを登録します。このイベントハンドラ内で、選択されたプロジェクトが PCL の時にメニューを表示するという処理を実装しています。

```csharp
private ConvertCommand(AsyncPackage package, OleMenuCommandService commandService)
{
    // ......
    var menuItem = new OleMenuCommand(this.Execute, menuCommandID);
    menuItem.BeforeQueryStatus += new EventHandler(OnBeforeQueryStatus);
}
```

イベントハンドラは、以下のように実装しています。

```csharp
private void OnBeforeQueryStatus(object sender, EventArgs e)
{
    ThreadHelper.ThrowIfNotOnUIThread();
    if (Dte == null)
        return;
    Project project = (Project)((object[])Dte.ActiveSolutionProjects)[0]; // Get the active project.
    if (project == null)
        return;
    OleMenuCommand cmd = (OleMenuCommand)sender;
    cmd.Visible = project.IsPclProject(Solution); // Custom extension method.
    cmd.Enabled = !Dte.OnBuilding() && !Dte.OnDebugging();
}
```

```OleMenuCommand``` の Visible プロパティを true に設定すると、メニューが表示状態になります。
また、```Enabled``` プロパティは、メニューが有効かどうかを設定することができ、ビルド中、デバッグ中にはメニューを選択できないようにしています。
```OnBuilding```, ```OnDebuging``` メソッドは拡張メソッドで以下のようになっています。

```csharp
internal static class DteExtension
{
    public static bool OnBuilding(this DTE dte)
    {
        Solution solution = dte.Solution;
        SolutionBuild solutionBuild = solution.SolutionBuild;
        vsBuildState buildState = solutionBuild.BuildState;
        return buildState == vsBuildState.vsBuildStateInProgress;
    }

    public static bool OnDebugging(this DTE dte)
    {
        Debugger debugger = dte.Debugger;
        return debugger.CurrentMode != dbgDebugMode.dbgDesignMode;
    }
}
```

```project.IsPclProject(Solution)``` は、独自に作成した拡張メソッドです。次の「当該プロジェクトが PCL かどうかを判定する」に説明を記載します。

### 当該プロジェクトが PCL かどうかを判定する

旧型式の Visual Studio のプロジェクトファイルには、プロジェクトファイルの中に ```ProjectTypeGuids``` というプロジェクトの種類を表す値が定義されたタグがあり、Visual Studio は、この GUID 値によってプロジェクトの種類を識別しています。以下に PCL プロジェクトのプロジェクトファイルの例を示します。

```xml
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <!-- 中略 -->
  <PropertyGroup>
    <!-- 中略 -->
    <ProjectTypeGuids>{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
    <!-- 中略 -->
  </PropertyGroup>
  <!-- 以下省略 -->
```

C# の PCL のプロジェクトタイプを表す GUID 値は、以下のようになります。

```text
{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
```

拡張機能では、Visual Studio 上で変換しようとするプロジェクトの ```ProjectTypeGuids``` の値を取得して、そのプロジェクトが PCL かどうかを判断しています。
```ProjectTypeGuids``` の値は、```IVsSolution``` インタフェースの ```GetProjectOfUniqueName``` を起点に取得しています。

```csharp
public static bool IsPclProject(this Project project, IVsSolution solution)
{
    solution.GetProjectOfUniqueName(project.UniqueName, out IVsHierarchy hierarchy);
    IVsAggregatableProjectCorrected ap = hierarchy as IVsAggregatableProjectCorrected;
    string projTypeGuids = null;
    ap?.GetAggregateProjectTypeGuids(out projTypeGuids);
    return projTypeGuids == "{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}";
}
```

### プロジェクトにインストールされた NuGet パッケージを取得する

プロジェクトにインストールされた NuGet パッケージを直接取得することはできないので、ソリューションにインストールされた NuGet パッケージのリストを取得して、パッケージがプロジェクトにインストールされているかどうかを判定しています。

- ```GetInstalledPackages``` メソッド
    - ソリューションで参照されている NuGet パッケージを取得します
- ```IsPackageInstalledEx``` メソッド
    - プロジェクトにパッケージがインストールされているかどうかを返します

```csharp
var nugetpackages = vsPackageInstallerServices.GetInstalledPackages().ToList();
foreach (var item in nugetpackages)
{
    if (vsPackageInstallerServices.IsPackageInstalledEx(project, item.Id, item.VersionString))
    {
        // Do something
    }
}
```

### プロジェクト参照を取得する

プロジェクト参照は、 ```EnvDTE``` ライブラリの ```Project``` インタフェースから ```VSLangProj.VSProject``` を取得し、```References``` プロパティから取得することができます。

```csharp
VSLangProj.VSProject vSProject = project.Object as VSLangProj.VSProject;
VSLangProj.References references = vSProject.References;
foreach (VSLangProj.Reference reference in references)
{
    if (reference.SourceProject != null)
    {
        // Do something
    }
}
```

### .NET Standard 2.0 プロジェクトファイルの作成

プロジェクトファイルの変換には、ランタイム T4 テキストテンプレートを利用しています。以下のような .NET Starndard 2.0 プロジェクトファイルのテンプレートを作成しておき、プロジェクトファイルを生成するときに、```PackageReference``` 要素に参照する NuGet パッケージの Id と バージョン、```ProjectReference``` 要素に参照するプロジェクトの相対パスを渡すようにしています。

```xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
<# foreach (var item in Packages)
{ #>
    <PackageReference Include="<#= item.Key #>" Version="<#= item.Value #>"/>
<# }#>
  </ItemGroup>
  
  <ItemGroup>
<# foreach (string include in ProjectReferences)
{ #>
    <ProjectReference Include="<#= include #>" />
<# }#>
  </ItemGroup>
</Project>
```

ランタイムテキストテンプレートのクラスに NuGet 参照、プロジェクト参照の情報を設定するためのプロパティを定義しておきます。

```csharp
internal partial class NetStandardTemplate
{
    // ....
    public Dictionary<string, string> Packages { get; set; }
    public List<string> ProjectReferences { get; set; }
    // ....
}
```

プロジェクトファイルを生成する処理は、```TransformText()``` メソッドを利用します。このメソッドは、文字列を返すので、```File.WriteAllText``` を利用してファイルに書き込みます。

```csharp
var converter = new NetStandardTemplate();
// .....
// converter.ProjectReferences にプロジェクト参照を設定
// converter.Packages に NuGet パッケージの参照に関する情報を設定

File.Delete(ProjectFullName);
var resultString = converter.TransformText();
File.WriteAllText(ProjectFullName, resultString, Encoding.UTF8);
```

## ソースコードとインストーラー

拡張機能のソースコードは、GitHub に公開しています。また、拡張機能のインストーラーは、Visual Studio Marketplace で公開しています。

- [ソースコード](https://github.com/tafuji/Pcl-To-NetStandard)
- [PCL TO .NET Standard](https://marketplace.visualstudio.com/items?itemName=tafuji.pcl2netstandard)

## 最後に

記事を書きながら、Xamarin というよりは Visual Studio の IDE の話になってしまったなと、少し反省しております。
今後も機会があれば、Xamarin の開発に少しでも役に立つようなツールをつくっていければと考えています。

- [Visual Studio extensibility documentation](https://docs.microsoft.com/en-us/visualstudio/extensibility/?view=vs-2017)

明日は、[atsushieno](https://qiita.com/atsushieno) さんの記事です。いつも、濃い内容の記事を書いてくださっているすごい方ですね。とても楽しみです。