Visual Studio Text Adornment VSIX using Roslyn

A VSIX is a way to install plugins into Visual Studio. The kind of plugin I'm interested in right now is a text adornment. This lets you superimpose things (text, pictures, interactive controls) right on top of a code editor window.

It took me a frustrating day figuring out how to get started. This post is to save everyone else the frustration.

 

Install the Visual Studio SDK

Its official title is Visual Studio Extensibility Tools. Full instructions are here. In a nutshell, quit VS and then do:

  1. Control Panel > Add Remove Programs > Microsoft Visual Studio 2015 > Modify
  2. Common Tools > Visual Studio Extensibility Tools > Ok

 

Install Roslyn SDK

Its official title is  .NET Compiler Platform SDK.

  1. Visual Studio > Tools > Extensions and Updates > Online
  2. Search for ".NET Compiler Platform SDK" > install it, and restart VS

 

Create a new VSIX project

Within Visual Studio,

  1. File > New > Project > Visual C# > Extensibility > VSIX Project
  2. This has created some files which we no longer need: delete index.html and stylesheet.css from your project

 

Add references

Officially the way you're supposed to add a text adornment to your project is right-click on it > Add > New Item > Visual C# > Extensibility > Editor > Editor Text Adornment, which also adds some references. But I found it generated unhelpful references, so I'm adding references manually instead. In a nutshell:

  1. Project > References > Add Reference > Framework > check System, PresentationCore, PresentationFramework, WindowsBase > OK
  2. Tools > Options > NuGet Package Manager > General > Automatically check for missing packages during build > ensure this button is checked > OK
  3. Right-click on project > Add > New Item > Visual C# Items > Web > JSON file, and call it "project.json"
  4. Edit the project json and replace its contents with what's below
  5. Right-click on project > Unload Project
  6. Right-click on project > Reload Project
  7. Build > Build Solution. This will produce warnings "Detected package downgrade System.Collections.Immutable from 1.2.0 to 1.1.37" -- these warnings are fine, and indeed necessary (see below).  But if there are errors about .NETFramework version, that's because of a mismatch between net46 specified in the project.json "frameworks" section and the .NET version in your Project > Properties > Application > TargetFramework. The minimum target is .net46. You should make sure both are in sync.
  8. Open source.extension.vsixmanifest > Assets > New, type = "MefComponent", source = "A project in current solution", and specify the project.
  9. The NuGet version numbers are specific to an update to VS2015: the versions "1.3.x" and "14.3.x" listed here require at least Update3; you should switch to the older "1.2.x" and "14.2.x" for something that will run on Update2.
 {
    "dependencies": {
        "Microsoft.CodeAnalysis": "1.3.0",
        "Microsoft.VisualStudio.LanguageServices": "1.3.0",
        "Microsoft.VisualStudio.Shell.14.0": "14.3.25407",
        "Microsoft.VisualStudio.Text.UI.Wpf": "14.3.25407",
        "System.Collections.Immutable": "1.1.37",
        "System.ComponentModel": "4.0.1",
        "VSSDK.ComponentModelHost": "12.0.4",
        "VSSDK.DTE": "7.0.4"
    },
    "frameworks": {
        "net46": { }
    },
    "runtimes": {
        "win": { }
    }
}

 

Note 1: As much as possible I'm using NuGet references instead of the traditional framework references under AddReference > Framework. I just prefer NuGet. I hope it'll make this project loadable/buildable even on a machine which doesn't have the Visual Studio SDK installed (but I haven't yet tried). Two of the packages are third-party ones written by the awesome .NET MVP Sam Harwell, which are basically just NuGet repackagings of parts of the VS SDK.

Note 2: For NuGet, I'm using the new-fangled project.json instead of packages.config. This is the faster cleaner way of referencing NuGet packages: with it, the References node in Solution Explorer only shows the 8 NuGets I'm referencing rather than their 20+ dependencies; it also means that NuGets are shared amongst all solutions, rather than being downloaded into a per-solution "packages" directory; and it means your project doesn't break when you change its directory relative to its .sln file. To activate project.json, you have to create the file, then unload your project, then reload your project, then nuget restore. Apparently the roadmap is for project.json to eventually be folded into .csproj, but until then, project.json is the best we can do.

Note 3: I have an explicit reference to an older version System.Collections.Immutable v1.1.37, which generates warnings every build. Had I removed this reference then it would have picked up the newer System.Collections.Immtuable v1.2.0 which would cause the VSIX to fail. The deal is that (as of VS2015 Update3) the Roslyn in your Visual Studio is still built upon v1.1.37, and if your project referenced a newer version, then the CLR would throw a MethodNotFound exception when it tries to enter a method that calls any of the ImmutableArray-returning Roslyn APIs.

 

Add code for the Text Adornment

We'll add source code for a simple text adornment.

  1. Right-click on your project > Add > Class
  2. Replace its contents with the following.

I've written out the code with as few "usings" as possible, instead using fully-qualified names. That's to help you understand the code at-a-glance, to see which library each type comes from. Feel free to switch over to "usings" once you know them!

 using System.Linq;

namespace MefRegistration
{
    using System.ComponentModel.Composition;
    using Microsoft.VisualStudio.Utilities;
    using Microsoft.VisualStudio.Text.Editor;

    [Export(typeof(IWpfTextViewCreationListener)), ContentType("text"), TextViewRole(PredefinedTextViewRoles.Document)]
    public sealed class TextAdornment1TextViewCreationListener : IWpfTextViewCreationListener
    {
        // This class will be instantiated the first time a text document is opened. (That's because our VSIX manifest
        // lists this project, i.e. this DLL, and VS scans all such listed DLLs to find all types with the right attributes).
        // The TextViewCreated event will be raised each time a text document tab is created. It won't be
        // raised for subsequent re-activation of an existing document tab.
        public void TextViewCreated(IWpfTextView textView) => new TextAdornment1(textView);

#pragma warning disable CS0169 // C# warning "the field editorAdornmentLayer is never used" -- but it is used, by MEF!
        [Export(typeof(AdornmentLayerDefinition)), Name("TextAdornment1"), Order(After = PredefinedAdornmentLayers.Selection, Before = Microsoft.VisualStudio.Text.Editor.PredefinedAdornmentLayers.Text)]
        private AdornmentLayerDefinition editorAdornmentLayer;
#pragma warning restore CS0169
    }
}


public sealed class TextAdornment1
{
    private readonly Microsoft.VisualStudio.Text.Editor.IWpfTextView View;
    private Microsoft.CodeAnalysis.Workspace Workspace;
    private Microsoft.CodeAnalysis.DocumentId DocumentId;
    private System.Windows.Controls.TextBlock Adornment;

    public TextAdornment1(Microsoft.VisualStudio.Text.Editor.IWpfTextView view)
    {
        var componentModel = (Microsoft.VisualStudio.ComponentModelHost.IComponentModel)Microsoft.VisualStudio.Shell.Package.GetGlobalService(typeof(Microsoft.VisualStudio.ComponentModelHost.SComponentModel));
        Workspace = componentModel.GetService<Microsoft.VisualStudio.LanguageServices.VisualStudioWorkspace>();

        View = view;
        View.LayoutChanged += OnLayoutChanged;
     }


    internal void OnLayoutChanged(object sender, Microsoft.VisualStudio.Text.Editor.TextViewLayoutChangedEventArgs e)
    {
        // Raised whenever the rendered text displayed in the ITextView changes - whenever the view does a layout
        // (which happens when DisplayTextLineContainingBufferPosition is called or in response to text or classification
        // changes), and also when the view scrolls or when its size changes.
        // Responsible for adding the adornment to any reformatted lines.

        // This code overlays the document version on line 0 of the file
        if (DocumentId == null)
        {
            var dte = Microsoft.VisualStudio.Shell.Package.GetGlobalService(typeof(EnvDTE.DTE)) as EnvDTE.DTE;
            var activeDocument = dte?.ActiveDocument; // sometimes we're constructed/invoked before ActiveDocument has been set
            if (activeDocument != null) DocumentId = Workspace.CurrentSolution.GetDocumentIdsWithFilePath(activeDocument.FullName).FirstOrDefault();
        }

        if (Adornment == null)
        {
            var line = e.NewOrReformattedLines.SingleOrDefault(l => l.Start.GetContainingLine().LineNumber == 0);
            if (line == null) return;
            var geometry = View.TextViewLines.GetMarkerGeometry(line.Extent);
            if (geometry == null) return;
            Adornment = new System.Windows.Controls.TextBlock { Width = 400, Height = geometry.Bounds.Height, Background = System.Windows.Media.Brushes.Yellow, Opacity = 0.5 };
            System.Windows.Controls.Canvas.SetLeft(Adornment, 300);
            System.Windows.Controls.Canvas.SetTop(Adornment, geometry.Bounds.Top);
            View.GetAdornmentLayer("TextAdornment1").AddAdornment(Microsoft.VisualStudio.Text.Editor.AdornmentPositioningBehavior.TextRelative, line.Extent, null, Adornment, (tag, ui) => Adornment = null);
        }

        if (DocumentId != null)
        {
            var document = Workspace.CurrentSolution.GetDocument(DocumentId);
            if (document == null) return;
            Microsoft.CodeAnalysis.VersionStamp version;
            if (!document.TryGetTextVersion(out version)) version = Microsoft.CodeAnalysis.VersionStamp.Default;
            Adornment.Text = version.ToString();
        }
    }
}

Note 1: VS text adornments normally only exist while they're in view on the screen. VS deletes the ones that have scrolled off-screen, and calls LayoutChanged when new lines get exposed so you can create adornments on-demand.

Note 2: Typical VS text adornments don't need to be "live", i.e. they can be created on demand and ignored after that. But I reckon that typical Roslyn-powered adornments will want to display stuff asynchronously. That's why I save a reference to the adornment that I created -- so my code can go back and update it at a later time (so long as it's still onscreen). If I did async work on a background thread then I'd need View.Dispatcher.Invoke to update the adornment on the UI thread.

 

Run it!

Press F5 to run it. It should launch a new "experimental" instance of VS, with your VSIX installed into it. When you open any text document you should see the new adornment in yellow:

adornment

 

How did it know how to launch and debug your VSIX?

  1. Project > Properties > Debug > Start External Program = "C:\Program Files (x86)\Microsoft Visual Studio 14.0\Common7\IDE\devenv.exe": when you created your VSIX project, it was set by default to launch a fresh instance of Visual Studio
  2. Project > Properties > Debug > Command Line Arguments = "/rootsuffix Exp": when VS is launched, it uses an "experimental hive" whose settings and extensions don't affect your main VS instance.
  3. Project > Properties > VSIX > Deploy VSIX content to experimental instance = "checked": when you start debugging, VS will install your VSIX into that experimental instance.

Sometimes (e.g. when you uninstall the VSIX from the experimental instance) that final part won't work right. I think that the experimental instance gets stuck thinking "this is a VSIX that has been deleted". The fix for that is to open Windows File Explorer, delete "%userprofile%\appdata\local\Microsoft\VisualStudio\Exp" and "\14.0Exp" and "\roaming\Microsoft\VisualStudio\Exp" and "\14.0Exp". Then launch Regedit and delete all the HKEY_CURRENT_USER\Software\Microsoft\VisualStudio\14.0Exp* keys.

What I like is, when I hit F5, for it to automatically load a test solution into that experimental instance of VS:

  1. Project > Properties > Debug > Command Line Arguments = "c:\...\ConsoleApplication1.sln" /rootsuffix Exp

 

I hope you have fun with Roslyn-powered editor adornments.

 

Troubleshooting

If it fails to hit breakpoints in your extension, check that it's even loaded: when you've done F5, hit the pause button, open up Debug > Windows > Modules, and look for the name of your DLL in the list. You might need to load symbols. If it's absent from the list, verify that you've added your project as an asset to source.extension.vsixmanifest.

If it crashes without even entering your method, this is often caused by assembly version mismatches. For instance, if your DLL was built against version 1.3.0 of Roslyn, but the current instance of Visual Studio is running version 1.2.2, then any method which invokes a Roslyn API will throw a MissingMethodException or TypeLoadException before the method is even entered.  You can test this by having a safe top-level method which only does "try {f();} catch (Exception ex) {Debug.WriteLine(ex.Message);}", and then move the proper body into f(). That way you'll at least find the MissingMethodException.

If you're getting a MissingMethodException or TypeLoadException, check you've referenced the right assemblies. To run in VS2015 you need to use the older System.Collections.Immutable v1.1.37. To run in Update2 you need to reference v1.2.x and v14.2.x of the NuGet packages.