Creating Visual Studio extensions using Roslyn analyzers

While most of my time goes into elmah.io, I love working on side projects too. Like the NuGet package updater tool NuPU and the Dark Screen of Death Chrome extension. A few weeks ago, I felt the need to solve a small problem I've had understanding Cron expressions. The idea was to create a Visual Studio (VS) extension presenting a human-readable version when hovering over the expression. In this post, I'll describe the steps needed to write the code. If you just came for the extension you can get it here: https://marketplace.visualstudio.com/items?itemName=elmahio.cronexpressions.

Creating Visual Studio extensions using Roslyn analyzers

Before we start coding, let's set the stage. When running through the steps in this post, we should have a VS extension able to show a human-readable version of a Cron expression when hovering over the string:

Hover Cron expression

See the 'At 08:00 AM, every day' text? That's what we are trying to achieve here.

To get started, create a new extension project using the template Empty VSIX Project:

Create Empty VSIX Project

If you haven't developed VS extensions before, you may not have this template installed. If not, launch the Visual Studio Installer, click Modify on the version of VS you are working on, and enable Visual Studio extension development:

Enable Visual Studio extension development

When created, you should see an almost empty project in VS:

Empty project in Visual Studio

The only file in the project is source.extension.vsixmanifest which contains metadata about the extension. We'll make changes to this file later in the post.

The tooltip will be implemented in VS as a concept called Quick Info. Quick Info is the overlay shown when hovering over classes, methods, variables, etc. as illustrated in the screenshot at the beginning of the post. VS comes with its own set of quick info overlays and you can extend the content inside each one easily. To add a new Quick Info, add a C# class to the project:

using Microsoft.VisualStudio.Language.Intellisense;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Utilities;
using System.ComponentModel.Composition;

namespace MyVSProject
{
    [Export(typeof(IAsyncQuickInfoSourceProvider))]
    [Name("Quick info when hovering cron expressions")]
    [Order(After = "default")]
    [ContentType("CSharp")]
    public class CronExpressions : IAsyncQuickInfoSourceProvider
    {
        public IAsyncQuickInfoSource TryCreateQuickInfoSource(ITextBuffer textBuffer)
        {
            return new CronExpressionQuickInfoSource(textBuffer);
        }
    }
}

You will need to reference the System.ComponentModel.Composition assembly. The class implements the IAsyncQuickInfoSourceProvider interface which is responsible for producing an instance of the IAsyncQuickInfoSource interface. This class will decide if a quick fix should be shown and in this case what content to include. To do so, we'll implement the CronExpressionQuickInfoSource class. You can create it in the same project as the provider class, but I'll create a new class library and reference that from the VSIX project. This makes it possible to write a unit test a bit later. Unit testing won't be covered in this post to keep the length from exploding but I'll write another post if anyone is interested.

Here's the solution hierarchy so far:

Before implementing the CronExpressionQuickInfoSource class, you need to add the following NuGet packages to the MyVSProject.CronExpression project:

<PackageReference Include="CronExpressionDescriptor" Version="2.19.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.4.0" />
<PackageReference Include="Microsoft.CodeAnalysis.EditorFeatures.Text" Version="4.4.0" />
<PackageReference Include="Microsoft.VisualStudio.CoreUtility" Version="17.4.255" />
<PackageReference Include="Microsoft.VisualStudio.Language" Version="17.4.255" />
<PackageReference Include="Microsoft.VisualStudio.Text.Data" Version="17.4.255" />

The CronExpressionDescriptor package contains a nice little feature able to produce a human-readable version of a Cron expression. Exactly what we need here. The rest of the NuGet packages are for the extension and Roslyn code we'll implement next.

Let's start with the class and method:

using Microsoft.VisualStudio.Language.Intellisense;
using System.Threading;
using System.Threading.Tasks;

namespace MyVSProject.CronExpression
{
    public class CronExpressionQuickInfoSource : IAsyncQuickInfoSource
    {
        private readonly ITextBuffer textBuffer;

        public CronExpressionQuickInfoSource(ITextBuffer textBuffer)
        {
            this.textBuffer = textBuffer;
        }

        public void Dispose()
        {
        }

        public async Task<QuickInfoItem> GetQuickInfoItemAsync(IAsyncQuickInfoSession session, CancellationToken cancellationToken)
        {
        }
    }
}

The interesting parts here are the IAsyncQuickInfoSource interface and the GetQuickInfoItemAsync method, responsible for returning a QuickInfoItem if the source should show one or null if not.

Would your users appreciate fewer errors?

➡️ Reduce errors by 90% with elmah.io error logging and uptime monitoring ⬅️

The first part of the GetQuickInfoItemAsync method will be responsible for getting the requester of quick info. VS extensions have various concepts like snapshots and trigger points which I won't go into detail about here. But here is the code:

var snapshot = textBuffer.CurrentSnapshot;

var triggerPoint = session.GetTriggerPoint(snapshot);
if (triggerPoint is null) return null;

var position = triggerPoint.Value.Position;

In short, the code gets a snapshot of the code in the editor as well as the location of the hovered item, which we will use later on. Every code line so far is specific to writing VS extensions. There are different ways to parse the code using extensions. Roslyn provides a more convenient way of traversing code, why we want to load the snapshot we just made into Roslyn. To do so, there's an adapter/bridge between the world of extensions and Roslyn, implemented by the GetOpenDocumentInCurrentContextWithChanges method:

var document = snapshot.GetOpenDocumentInCurrentContextWithChanges();

The document returned here is an Microsoft.CodeAnalysis.Document object that you probably recognize if you have played around with Roslyn before.

I'll create a new method named CalculateQuickInfoAsync to parse the code and return a quick info message if one should be shown:

var quickInfo = await CalculateQuickInfoAsync(document, position, cancellationToken);
if (quickInfo is null) return null;

return new QuickInfoItem(
    snapshot.CreateTrackingSpan(
        new Span(quickInfo.Value.span.Start, quickInfo.Value.span.Length),
        SpanTrackingMode.EdgeExclusive),
    new ContainerElement(ContainerElementStyle.Stacked, quickInfo.Value.message));

The CalculateQuickInfoAsync method has the following body:

public static async Task<(string message, TextSpan span)?> CalculateQuickInfoAsync(Document document, int position, CancellationToken cancellationToken)
{
}

It takes the document and position as parameters and returns a quick info as a message and span. The span is a concept that I will touch upon in a bit. Here's the full method which may look strange but I'll go through the lines one by one:

var rootNode = await document.GetSyntaxRootAsync(cancellationToken);
var node = rootNode.FindNode(TextSpan.FromBounds(position, position));

if (!(node is SyntaxNode identifier)) return null;

LiteralExpressionSyntax literalExpressionSyntax = null;

if (node is ArgumentSyntax argumentSyntax)
    literalExpressionSyntax = argumentSyntax.Expression as LiteralExpressionSyntax;
else if (node is AttributeArgumentSyntax attributeArgumentSyntax)
    literalExpressionSyntax = attributeArgumentSyntax.Expression as LiteralExpressionSyntax;
else if (node is LiteralExpressionSyntax literalExpressionSyntax1)
    literalExpressionSyntax = literalExpressionSyntax1;

if (literalExpressionSyntax == null) return null;
else if (literalExpressionSyntax.Kind() != Microsoft.CodeAnalysis.CSharp.SyntaxKind.StringLiteralExpression) return null;

var text = literalExpressionSyntax.GetText()?.ToString();
if (string.IsNullOrWhiteSpace(text)) return null;

var expression = text.TrimStart('\"').TrimEnd('\"');

string message = null;
try
{
    message = ExpressionDescriptor.GetDescription(expression, new Options
    {
        Use24HourTimeFormat = DateTimeFormatInfo.CurrentInfo.ShortTimePattern.Contains("H"),
        ThrowExceptionOnParseError = false,
        Verbose = true,
    });
}
catch
{
    return null;
}

if (string.IsNullOrWhiteSpace(message) || message.StartsWith("error: ", System.StringComparison.InvariantCultureIgnoreCase)) return null;

var span = identifier.Span;
return (message, span);

In the first two lines, I use Roslyn to locate the currently hovered node from the position reported by the trigger point in the previous method. Next, we need to look at the hovered node to try and see if this is something worth looking more at. Remember that so far we didn't tell VS exactly what to look for, why this quick info source will be requested every time VS need to show quick info. For this extension, we only want to look at strings. This can be strings in variables, arguments, attributes, and more. Roslyn provides different ways of identifying strings depending on where they are specified. TBH, this code was probably the hardest to write while developing the extension. Mainly because I wasn't an expert in Roslyn and because I didn't know the following. To easily get the right syntax class names, simply open the Syntax Visualizer window in VS and click a string somewhere:

The tree structure shows the composition of the code seen from the eyes of Roslyn and tells you exactly which classes to look for.

Back to the method. In case we found what's called a LiteralExpressionSyntax and that kind is a string literal, the code will continue to run. There might be better ways to write this and I'm very open to suggestions here.

The final thing to do is to take the value of the string literal and run it through the CronExpressionDescriptor package. In case CronExpressionDescriptor returns a value, we know that this was successfully parsed as a cron expression, and a human-readable string was produced. This string is returned from the method together with a span. A span tells VS how much of the code to show the quick info for. For this example, we want the quick info shown as long as the user hovers over any characters of the string.

You may have lost the big picture of the code base right now. For the full code sample, check out the following GitHub repository: https://github.com/elmahio/CronExpressions.

The only thing missing before building and publishing the VS extension is to update the source.extension.vsixmanifest file. Open it and navigate to the Assets tab. Click the New button, and add a new MEF component:

MEF components are used to tell VS that you have implemented a piece of code that it should take into consideration when loading the extension.

That's it! Launching the project using F5 should now open an experimental instance of VS and show quick info with human-readable text when hovering Cron expressions. Both unit testing, build, and deployment have been left out of this post. If anyone is interested let me know and I'll write a part 2.