回転寿司をVisual Studioに降臨させる – Visual Studio Advent Calendar 2016

この記事は、Visual Studio Advent Calendar 2016の4日目の記事です。

sushi_rotation_on_mbp少し前に、新しいMacBook Proで回転寿司が話題になりましたね。こんな奴です:

centerclr-sushirotatorこれが転じて、最終的にVisual Studioの拡張機能として、こんなものを作りました:

Visual Studioの拡張機能… 私は度々dis… もとい、非常に難易度が高いという話をしているのですが、この拡張機能の実装はそれほど難しくありません。とは言え、罠が至る所に待ち構えています。今回は、拡張機能を作るうえで躓いてツマラナイ思いをしないように、導入部分のガイドを記録に残しておこうと思います。

なお、この拡張機能はGitHubで公開しています: CenterCLR.SushiRotator
すぐに試してみたい方は: Visual Studio Market place、または拡張機能から「sushi」で検索すれば出てきます。

# やじうまの杜でも取り上げられました: MacのTouch Barが回転寿司に! エディターでも続々カイテン


事前に考える必要のある事

Visual Studioの拡張機能は、俗に「VSIX」と呼ばれています。これは、拡張機能のパッケージに「CenterCLR.SushiRotator.vsix」のような拡張子が付いていることでもわかります。
ややこしいのは、拡張機能のすべてにvsix拡張子がついているわけではなく、一般的なセットアップインストーラーだったり、msi形式だったする事もあることです。

VSIX形式は、Visual Studio(のVSIXInstaller.exe)を使ってインストールします。また、VSIXでは、Visual Studio Market placeからの自動更新に対応していて、ユーザーレベルでのインストールにも対応しているため、ユーザーからすると扱いやすいものになっています。例えば、拡張機能がGACへのインストールを必要としているなどの複雑な処理を行う場合は、VSIX形式とすることはできないため、msiや独自のインストーラーを作る必要があります。

今回は、要するにエディタを拡張するだけであり、エディタの拡張ならVSIXだけで完結するため、標準的なVSIX形式で作ります。

また、対象のVisual Studioを何にするかも考える必要があります。Visual Studioの拡張機能は、以下のエディション(以上)でサポートされます:

  • Visual Studio 2005 Professional
  • Visual Studio 2008 Professional
  • Visual Studio 2010 Professional
  • Visual Studio 2012 Professional
  • Visual Studio 2013 Community
  • Visual Studio 2015 Community
  • Visual Studio 2017RC Community

より古いバージョンに対応させようとすると、それだけ拡張可能な機能が制限されます(機能拡張に対応するインターフェイスが定義されていないなど)。

これ以前のバージョン(2003など)でも、基本的なインターフェイスは実装されています(それどころか、古のVisual InterDevやJ++でも)。しかし、これらのバージョンでの拡張は、マイクロソフトと特別な契約を結んだデベロッパだけが開発することが出来たため、ほとんどの皆さんは対応させることが出来ません(まあ実際には書いたら動くのかもしれませんが。それから、既にサポート対象外なので、実質無視して良いと思います)。

また、以下の制限もあります:

  • 各バージョンのExpress Editionは、拡張機能に対応していません。
  • VSIXパッケージをサポートするのは、Visual Studio 2010以上です。2010未満は、カスタムインストーラーが必要です。当然、Market placeでの自動更新はできません。
  • 今回対象とする「テキストエディタの拡張」は、Visual Studio 2010以上でのみサポートされます。これは、2010以降はVisual StudioがWPFで書き直され、WPFコントロールを使う前提となっていることが遠因となっていると思われます。
  • Visual Studio 2012以降は、後述のvsixmanifestファイルの形式が変更されたため、2010をサポートする場合は古い形式で記述する必要があります。
  • それぞれの拡張機能を実装するには、対応するVisual Studio SDKが必要です。例えば、2010の場合は、VSSDK2010が必要となります。これらは下位互換性があるため、普通は目指すバージョン群の最も高いバージョンに対応するSDKをインストールすれば、問題ありません。
  • 使用する.NET Frameworkのバージョンにも注意して下さい。対応させる最も古いVisual Studioで開発可能な、最後のバージョンを選択します。(大丈夫だと思いますが、.NET Coreは使えません、念のため)。

vs2017rc_install_vsix上記の事を考え、今回はVisual Studio 2012 Professional以上、つい先日リリースされた2017RCまでを対象とします。SDKは2017RCのものが必要ですが、これは2017RCのインストーラーで選択すればインストール出来ます。.NET Frameworkは4.5を使用します。

ややこしいのは、拡張機能の開発自体は、対応させる最も新しいVisual Studioを使う必要がある、と言うことです。したがって、2017RCを使用します。


拡張機能のプロジェクトテンプレート

vsix1VSIXの拡張機能を作るには、プロジェクトの新規作成から「Extensibility」にある「VSIX Project」でプロジェクトを作成します:

vsix2次に、プロジェクトにエディタ拡張を追加します。これも、Extensibility配下から選択します。いくつか候補がありますが、ここでは「Editor Margin」を選択します。

vsix3このEditor Marginが、テキストエディタの一部の領域を占有する事が出来る拡張です。これを使って寿司を流します。

これらのテンプレートですが、以前は本当にくぁwせdrftgyふじこlp… いや、色々と問題があった… のですが、2017RCでは非常にすっきりしたテンプレートとなっていて、扱いやすく修正されました。基本となるVSIX Projectでプロジェクトを作っておいて、そこに必要なitemとして後から追加する感じです。但し、拡張機能で実現可能な機能に対して、圧倒的にテンプレートが足りてません。大半は一から手でクラスを書いたりする必要があります。

# 何故かテキストエディタに対する拡張のテンプレートだけが色々あります… もちろん、本当はもっと多彩な事が出来ます。


Editor Margin

Editor Marginは、テキストエディタの一部領域が、拡張機能のために予約され、そこに自由に何かを表示させることが出来るVSIXの拡張機能です。この一部領域とはつまり、WPFのCanvasであり、まあ、あとはWPFで好きなようにやってよ、という感じです。Editor Marginクラスを加えると、以下のようなテンプレートコードが生成されます:
(Disposeの面倒を見るコードもあるんですが、長いので省略)

namespace VSIXProject1
{
    /// <summary>
    /// Margin's canvas and visual definition including both size and content
    /// </summary>
    internal class EditorMargin1 : Canvas, IWpfTextViewMargin
    {
        /// <summary>
        /// Margin name.
        /// </summary>
        public const string MarginName = "EditorMargin1";

        /// <summary>
        /// Initializes a new instance of the <see cref="EditorMargin1"/> class for a given <paramref name="textView"/>.
        /// </summary>
        /// <param name="textView">The <see cref="IWpfTextView"/> to attach the margin to.</param>
        public EditorMargin1(IWpfTextView textView)
        {
            this.Height = 20; // Margin height sufficient to have the label
            this.ClipToBounds = true;
            this.Background = new SolidColorBrush(Colors.LightGreen);

            // Add a green colored label that says "Hello EditorMargin1"
            var label = new Label
            {
                Background = new SolidColorBrush(Colors.LightGreen),
                Content = "Hello EditorMargin1",
            };

            this.Children.Add(label);
        }

        #region IWpfTextViewMargin
        /// <summary>
        /// Gets the <see cref="Sytem.Windows.FrameworkElement"/> that implements the visual representation of the margin.
        /// </summary>
        /// <exception cref="ObjectDisposedException">The margin is disposed.</exception>
        public FrameworkElement VisualElement
        {
            // Since this margin implements Canvas, this is the object which renders
            // the margin.
            get
            {
                this.ThrowIfDisposed();
                return this;
            }
        }
        #endregion

        #region ITextViewMargin
        /// <summary>
        /// Gets the size of the margin.
        /// </summary>
        /// <remarks>
        /// For a horizontal margin this is the height of the margin,
        /// since the width will be determined by the <see cref="ITextView"/>.
        /// For a vertical margin this is the width of the margin,
        /// since the height will be determined by the <see cref="ITextView"/>.
        /// </remarks>
        /// <exception cref="ObjectDisposedException">The margin is disposed.</exception>
        public double MarginSize
        {
            get
            {
                this.ThrowIfDisposed();

                // Since this is a horizontal margin, its width will be bound to the width of the text view.
                // Therefore, its size is its height.
                return this.ActualHeight;
            }
        }

        /// <summary>
        /// Gets a value indicating whether the margin is enabled.
        /// </summary>
        /// <exception cref="ObjectDisposedException">The margin is disposed.</exception>
        public bool Enabled
        {
            get
            {
                this.ThrowIfDisposed();

                // The margin should always be enabled
                return true;
            }
        }

        /// <summary>
        /// Gets the <see cref="ITextViewMargin"/> with the given <paramref name="marginName"/> or null if no match is found
        /// </summary>
        /// <param name="marginName">The name of the <see cref="ITextViewMargin"/></param>
        /// <returns>The <see cref="ITextViewMargin"/> named <paramref name="marginName"/>, or null if no match is found.</returns>
        /// <remarks>
        /// A margin returns itself if it is passed its own name. If the name does not match and it is a container margin, it
        /// forwards the call to its children. Margin name comparisons are case-insensitive.
        /// </remarks>
        /// <exception cref="ArgumentNullException"><paramref name="marginName"/> is null.</exception>
        public ITextViewMargin GetTextViewMargin(string marginName)
        {
            return string.Equals(marginName, EditorMargin1.MarginName, StringComparison.OrdinalIgnoreCase) ? this : null;
        }
        #endregion
    }
}

コンストラクタで渡される引数IWpfTextViewと、IWpfTextViewMarginというインターフェイスの実装が、拡張機能をサポートする要となっています。また、このクラスはCanvasクラスを継承していますね。サンプルの実装は、このCanvasにLabelを手動生成して追加しています。やる気になれば普通にXAMLで書いても動きそうな気配です(試してません)。

コンストラクタの引数は依存注入されるのでしょう(Visual StudioはMEFを使用しているので、MEF経由で渡されるようです)。(間違えました、以下で示すファクトリクラスから生成しています)注意すべき点としては、このクラスにはstaticフィールドを含めることが出来ない、と言う事です。含めた場合、拡張機能のロード時に失敗します(結構悩んだ。Visual Studioのバグかもしれない)。staticフィールドが欲しい場合は、別のクラスに定義して、そこを参照するようにします(回転寿司でもやっています)。

サンプルのコードはコンストラクタ内で、Heightを20pxに指定しています。Canvasなので、このサイズが表示領域を規定することになります。また、WPFは標準でクリッピングを行いません。手動描画した場合に描画がはみ出ると、エディタ領域が酷いことになってしまうため、ClipToBoundsをtrueとしているようです。はみ出す可能性がなく、パフォーマンスに問題がある拡張機能を実装するのであれば、これをfalseとしても良いでしょう(どんな拡張機能なのかは良くわからない…)。

まんまWPFなので、もちろんアニメーションも使えます。回転寿司では、寿司を流すのにWPFのアニメーションを使っています。なので、非常に滑らかに流れることがわかると思います(上のGIFアニメーションはガタガタですが、ぜひ実際にインストールして見て下さい)。


Editor Margin Factory

Editor Marginクラスは、ファクトリクラスとの関連付けで実行されます。ファクトリクラスはEditor Marginテンプレートを追加した際に、自動的に追加されているはずです。以下に例を示します:

namespace VSIXProject1
{
    /// <summary>
    /// Export a <see cref="IWpfTextViewMarginProvider"/>, which returns an instance of the margin for the editor to use.
    /// </summary>
    [Export(typeof(IWpfTextViewMarginProvider))]
    [Name(EditorMargin1.MarginName)]
    [Order(After = PredefinedMarginNames.HorizontalScrollBar)]  // Ensure that the margin occurs below the horizontal scrollbar
    [MarginContainer(PredefinedMarginNames.Bottom)]             // Set the container to the bottom of the editor window
    [ContentType("text")]                                       // Show this margin for all text-based types
    [TextViewRole(PredefinedTextViewRoles.Interactive)]
    internal sealed class EditorMargin1Factory : IWpfTextViewMarginProvider
    {
        #region IWpfTextViewMarginProvider
        /// <summary>
        /// Creates an <see cref="IWpfTextViewMargin"/> for the given <see cref="IWpfTextViewHost"/>.
        /// </summary>
        /// <param name="wpfTextViewHost">The <see cref="IWpfTextViewHost"/> for which to create the <see cref="IWpfTextViewMargin"/>.</param>
        /// <param name="marginContainer">The margin that will contain the newly-created margin.</param>
        /// <returns>The <see cref="IWpfTextViewMargin"/>.
        /// The value may be null if this <see cref="IWpfTextViewMarginProvider"/> does not participate for this context.
        /// </returns>
        public IWpfTextViewMargin CreateMargin(IWpfTextViewHost wpfTextViewHost, IWpfTextViewMargin marginContainer)
        {
            return new EditorMargin1(wpfTextViewHost.TextView);
        }
        #endregion
    }
}

初見殺し的なひどいコードですが、このクラスの属性から、Visual StudioがEditor Marginのコードを認識して、実行可能となります。いじるのは、色々わかってからの方が良いでしょう。


vsixmanifestファイル

VSIX Projectプロジェクトを生成すると、*.vsixmanifestというファイルも追加されます。このファイルが、VSIXをパッケージングするのに使われる定義体で、中身はXMLです。NuGetで言う所のnupkgに対応するnuspecファイルです。

<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
  <Metadata>
    <Identity Id="VSIXProject1.Kouji Matsui.86820e26-2ec6-496e-ad20-38a2ba88bf46" Version="1.0" Language="en-US" Publisher="Kouji Matsui" />
    <DisplayName>VSIXProject1</DisplayName>
    <Description>Empty VSIX Project.</Description>
  </Metadata>
  <Installation>
    <InstallationTarget Id="Microsoft.VisualStudio.Community" Version="[15.0]" />
  </Installation>
  <Dependencies>
    <Dependency Id="Microsoft.Framework.NDP" DisplayName="Microsoft .NET Framework" d:Source="Manual" Version="[4.5,)" />
  </Dependencies>
  <Prerequisites>
    <Prerequisite Id="Microsoft.VisualStudio.Component.CoreEditor" Version="[15.0,16.0)" DisplayName="Visual Studio core editor" />
  </Prerequisites>
  <Assets>
    <Asset Type="Microsoft.VisualStudio.MefComponent" d:Source="Project" d:ProjectName="%CurrentProject%" Path="|%CurrentProject%|" />
  </Assets>
</PackageManifest>

XMLはこのような形式です(上で述べたように、2010に対応させるには異なる形式で記述する必要があります)。但し、SDKがインストールされていれば、ソリューションエクスプローラーからダブルクリックで開くと、以下のようにビジュアル編集出来ます:

vsix4パッケージをMarket placeで公開する場合は、Identity・DisplayName・Descriptionなどをきちんと埋めておきましょう。他にもIcon・PreviewImage・License・Tagsなどが指定出来ます。

vsix5また、重要な項目として、Dependenciesの.NET Frameworkのバージョンと、Prerequisitesの「Visual Studio core editor」のバージョンを合わせておく必要があります。

vsix6Version Rangeの指定は、数学で使うような、以上・未満の記号による指定を行います。バージョン11(2012)以上~16(2018?)未満の場合、”[11.0, 16.0)”のように、以上を角括弧、未満を丸括弧で示します。このとき、”[11.0, 15.0]”としてはいけません。これでは、2017のマイナーバージョンアップに対応出来なくなってしまいます(15(2017)「以下」なので、マイナーバージョンが上がると外れてしまう)。

InstallationTargetは複数定義できます。最初に検討した、対応させるVisual Studioのバージョンとエディションの組み合わせが複雑になるので、以下のように定義するとよいと思います:

  <!-- これは回転寿司で使っている定義です。
       InstalledByMsiはmsiでインストールするかどうか、AllUsersは昇格が必要かどうかです。 -->
  <Installation InstalledByMsi="false" AllUsers="false">
    <!-- VS2012 -->
    <InstallationTarget Id="Microsoft.VisualStudio.Pro" Version="[11.0,12.0)" />
    <!-- VS2013- -->
    <InstallationTarget Id="Microsoft.VisualStudio.Community" Version="[12.0,16.0)" />
    <InstallationTarget Id="Microsoft.VisualStudio.Pro" Version="[12.0,16.0)" />
  </Installation>

いつも通りと言うか、一旦vsixmanifestを生成したら、後は手動でXMLを弄った方が早い感じがひしひしと…


VSIXをデバッグする

このプロジェクトをビルドすると、bin\Debug(またはbin\Release)に、*.vsixが生成されます。デバッグ実行すると、検証用のVisual Studioが別途起動し、そこにVSIXが自動登録されてデバッグ可能になります。以下はデバッグ実行した例です。右側が開発中のVisual Studio、左側がデバッグ実行で新たに起動した「実験的なインスタンス」と命名されているVisual Studioで、その上で寿司が流れています。

vsix7

Visual Studio 2015以降で特徴的な、WPFライブビュー用のツールバーも表示されていることがわかります。

ところで、デバッグしていると途中で動作がおかしくなったりすることがあります。特に、変更したコードが反映されていないなどです。これは、VSIXのインストールフォルダが何らかの問題で破損した場合に発生します。インストールフォルダはユーザー毎に作られます。場所は、「C:\Users\[ユーザー名]\AppData\Local\Microsoft\VisualStudio」のような場所です。この配下に、Visual Studioのバージョン番号毎にフォルダが作られ、その中にインストールされます。

デバッグ実行した場合も、このフォルダ配下にコピーされます。2017RCの場合、サブフォルダは「15.0_31113400Exp」です。最後の「Exp」が「実験的なインスタンス」に対応します。何か動きがおかしいと思ったら、このフォルダを丸ごと削除して再度ビルドしてみて下さい(Visual Studioは一旦終了させる必要があるかもしれません)。削除しても、次回起動時には再生成されます。なお、経験的には「わりとよく壊れます」。アッハイとかつぶやきながら、削除すると良いでしょう。

それから、最新のVisual Studio対応バージョンを上げる場合(例えば、2013対応のものを2017RCに対応させる)、デバッグ実行時に起動するdevenv.exeへのパスも更新することをお忘れなく。「実験的なインスタンス」へのデプロイは最新のVisual Studioに行われる(これはSDKの機能で行われる)のに、起動するVisual Studioが古いバージョンのままだと、VSIXがロードされないように見えて混乱します。この問題も忘れたころにハマるパターンです。

C#のプロジェクトは、デバッグ時の起動パスを*.csproj.userに保存します。通常、Gitでソースコード管理していると、このファイルは管理対象外とする(.gitignoreで除外)のですが、ここにパスが書かれているため、git cloneしてデバッグしようとして失敗するのも良くハマります。*.csprojに手動で定義を移しておくとベターですが、そうすると今度は*.csproj.userでいつの間にかオーバーライドされていてハマるという、非常に苦しい展開が待っています…

# オーバーライドを無視するか警告する属性が欲しい…


VSIXのNuGetと低いバージョンに対応させる話

2017RCのVSIX Projectでプロジェクトを作ると、VSIXで必要なライブラリが、NuGet経由で参照されます。このパッケージは「Microsoft.VSSDK.BuildTools」「Microsoft.VisualStudio.CoreUtility」「Microsoft.VisualStudio.Text.UI」などで公開されています。これらのパッケージが公開されていることを知ったのは今回の回転寿司によるんですが、昔はNuGetパッケージ化されていなかったため、VSIXを含むプロジェクトのビルドは、非常に面倒な問題を含んでいました(当然、CIするにはVisual Studio本体が必要だったり)。

今はこのように、VSIXテンプレートとは別体となったおかげで、ずいぶんやりやすくなったと思います。NuGet経由での参照となったのは把握していませんが、ごく最近のバージョンからの筈です。

が、このパッケージの最低バージョンが2013以降のようで、2012やそれ以前のバージョンには対応していません。もし、古いバージョンも対応させる予定であるなら、別のパッケージ「VSSDK.CoreUtility」が公開されているので、これを使うと良いと思います(StyleCop Analyzersのauthorですね)。NuGetで「VSSDK」で検索すると、細分化されたパッケージのリストが表示されるので、必要なものを追加します。但し、VSIXのビルドシステムだけは最新のパッケージを使う必要があります(vsixmanifestの最新のスキーマを認識可能にするため)。つまり、「Microsoft.VSSDK.BuildTools」は、最初に挙げたもの(の最新版)を使うことをお勧めします。

昔のVSIXテンプレートから生成したプロジェクトを保守している皆様… ご愁傷さまです。手動でゴリゴリcsprojを書き換える未来が待っております… (泣


まとめ

全体的なコードは、回転寿司を参考にして見て下さい。VSIXのパッケージをローカルデバッグ出来ていよいよ公開できるようになったら、Visual Studio Market placeで公開できます。公開は無料で可能です。公開プロセスは変更中のようで、現在は Visual Studio Team Onlineで使う拡張機能と統合したいのか、変に中途半端になっている感じです。Market placeからのリンクで申請しようとすると VSTO 向けの公開として扱われて、認証プロセスがどうのとか面倒な事になります。

vsix8「Visual Studio Gallery」と呼ばれているこっちから行き、中ほどにある「アップロード」をクリックすると確実です。アップロードする前に、vsixmanifestの内容を見直してください。また、バージョンアップする場合は、vsixmanifestのバージョン番号を更新することをお忘れなく。

一応、アップロードした時点で、パッケージングが正しいかどうかの検証は行ってくれるようです。なお、更新されたパッケージをアップロードすると、使用中のユーザーにはおなじみのように「拡張機能の更新があります」と通知されます。

何か面白い拡張機能作って放流してください :)
それでは。