Writing a visual studio extension for code generation with roslyn

Recently I've been working on a Visual Studio extension to automate the tedious parts of code authoring at work. It turns out that there is a lot of knowledge out there, but quite spread. Here's my try to get a somewhat short, end-to-end tutorial on how to create a new Visual Studio extension, with Roslyn, parse some code and then generate some more.

I'll assume you know nothing about Visual Studio extensions and Roslyn, but, at least, install Visual Studio SDK first.

Creating a new extension

So the goal is to show that small light bulb near some piece of code and when the user clicks on it we can generate some code based on the context around it. Like the one below:

Visual Studio Light Bulb Suggestion

Microsoft Docs has a walk-through on how to create a new extension that shows the light bulb. Here's the short version of it.

  1. Create a new Visual Studio extension, in Visual Studio, by clicking File -> New Project -> VSIX Project (it will be under Templates -> Visual C# -> Extensibility)
  2. Install the following NuGet packages (solution explorer -> Manage Nuget Packages -> Select the Browse tab -> Search for package -> click Install):
    • Microsoft.VSSDK.BuildTools
    • Microsoft.VisualStudio.Language.Intellisense
    • Microsoft.VisualStudio.Imaging.Interop.14.0.DesignTime
  3. Add a reference to the following assemblies (right clicking in References element under your project in the Project Explorer window, select "Add reference")
    • System.ComponentModel.Composition
  4. Open your .csproj and change the following properties to true:
<IncludeAssemblyInVSIXContainer>true</IncludeAssemblyInVSIXContainer>  
<IncludeDebugSymbolsInVSIXContainer>true</IncludeDebugSymbolsInVSIXContainer>  
<IncludeDebugSymbolsInLocalVSIXDeployment>true</IncludeDebugSymbolsInLocalVSIXDeployment>  

Add the following to the bottom of your source.extension.vsixmanifest:

<Assets>  
     <Asset Type="Microsoft.VisualStudio.MefComponent" d:Source="Project" d:ProjectName="%CurrentProject%" Path="|%CurrentProject%|" />
</Assets>  

If you are curious, the .csproj changes tells the VisualStudio SDK build tools to package the assembly inside the VSIX package, and the asset element in the vsixmanifest file tells VisualStudio where to look for DLLs when it is composing assemblies when MEF does its thing.

The project is all set up now. Let's move on to adding the light bulb suggestion.

Adding the light bulb action

There are three interfaces of interest.

  • ISuggestedActionsSourceProvider is a factory of ISuggestedActionsSource, which is the one responsible for telling Visual Studio whether to show the menu item for our light bulb action, and if so, providing the an implementation of ISuggestedAction that will perform the action.

For a performant extension, you should do minimal work in both the first two implementations and let the hard lifting happen on the implementation of ISuggestedAction. Having said that, here's the bare minimum code to have a light bulb show up:

using System;  
using System.Collections.Generic;  
using System.ComponentModel.Composition;  
using System.Threading;  
using System.Threading.Tasks;  
using Microsoft.VisualStudio.Imaging.Interop;  
using Microsoft.VisualStudio.Language.Intellisense;  
using Microsoft.VisualStudio.Text;  
using Microsoft.VisualStudio.Text.Editor;  
using Microsoft.VisualStudio.Utilities;

namespace Sample  
{
    [Export(typeof(ISuggestedActionsSourceProvider))]
    [Name("Test Suggested Actions")]
    [ContentType("code")]
    public class ActionsSourceProvider : ISuggestedActionsSourceProvider
    {
        public ISuggestedActionsSource CreateSuggestedActionsSource(ITextView textView, ITextBuffer textBuffer)
        {
            return new ActionSource();
        }
    }

    public class ActionSource : ISuggestedActionsSource
    {
        public event EventHandler<EventArgs> SuggestedActionsChanged;

        public void Dispose()
        {
        }

        public IEnumerable<SuggestedActionSet> GetSuggestedActions(ISuggestedActionCategorySet requestedActionCategories, SnapshotSpan range, CancellationToken cancellationToken)
        {
            return new[] { new SuggestedActionSet(new[] { new Action() }) };
        }

        public Task<bool> HasSuggestedActionsAsync(ISuggestedActionCategorySet requestedActionCategories, SnapshotSpan range, CancellationToken cancellationToken)
        {
            return Task.FromResult(true);
        }

        public bool TryGetTelemetryId(out Guid telemetryId)
        {
            telemetryId = default(Guid);
            return false;
        }
    }

    public class Action : ISuggestedAction
    {
        public bool HasActionSets => false;
        public string DisplayText => "Do some code magic";
        public object IconMoniker => string.Empty;
        public string IconAutomationText => string.Empty;
        public string InputGestureText => string.Empty;
        public bool HasPreview => false;
        ImageMoniker ISuggestedAction.IconMoniker => default(ImageMoniker);

        public void Dispose()
        {
        }

        public Task<IEnumerable<SuggestedActionSet>> GetActionSetsAsync(CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<object> GetPreviewAsync(CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public void Invoke(CancellationToken cancellationToken)
        {
        }

        public bool TryGetTelemetryId(out Guid telemetryId)
        {
            telemetryId = default(Guid);
            return false;
        }
    }
}

If you hit F5 to start a debugging session, a new instance of Visual Studio should open. Create a new project there, create a .cs file, and somewhere on the file, reach with the mouse to the side or press Ctrl+. (control and dot) and you should see a new entry in the light bulb suggestion menu for "Do some code magic".

Code magic

If you are curious on how Visual Studio found your action since all we did was implement a few classes, remember the setup we did initially. That was to configure our package to play in the composition process of Visual Studio. When our package is loaded, Visual Studio will check all classes that have the Export attribute, and make them available to other consumers, including the Visual Studio Editor that will query all implementations of ISuggestedActionsSourceProvider for action sources.

Ok, now we need to understand what is going on and decide when to show the action and when not to.

Understanding what's around

A bit of Roslyn for you

To get some information on the code that the user wants to perform an action on, we will use Roslyn. It is the new .NET compiler platform, it has a tokenizer, parser and compiler for different .NET languages all in there. That means, if you really wanted to, you could make your extension language agnostic.

Roslyn also abstracts the hosting environment, or as they call it, workspace. That lets you write code without binding to a specific IDE or editor, so you could reuse your component to light up your scenario in different editors. I think, even if you end up not doing so, separating your editor specific code from the rest of your business logic brings you better maintainability (not to say that it helps with testability - soon enough you are going to notice that waiting for a new Visual Studio window to load and then loading a project there to test your changes is a productivity no-no). If you decided to split it, you can do all that follows in a new project, and reference the new project in your existing Visual Studio extension project.

Quick intro

I do recommend checking out their documentation, but if you must skip it, know this:

  1. Many Roslin elements are immutable, that means that if you want to change something, say, add a new method to a class definition, you call an API to do so, but that returns you a new class definition object and then you need to call another API to "commit" the changes. Remember this... really, it is very easy to spend a lot of time trying to find why you "clazz.AddMember()" is not doing anything :)

  2. This is simplifying things a lot: you have a Workspace which is a representation of your IDE/Editor, projects and files. A file is called a Document, a document has associated with it the SyntaxTree which is a graph representation of every single token (all words and symbols that make sense for the language, for instance, on the string public static XDocument myVariable; you have 4 tokens, public, static, XDocument, myVariable) in the document. You can get the SemanticModel for the SyntaxTree, which is a higher level analysis of the code. For instance, in the previous example "public static int myVariable", while the SyntaxTree can tell you that you have a variable declaration, the semantic model can tell you that the variable is of type XDocument and that XDocument inherits from XContainer, that sits on the namespace System.Xml.Linq, and so on.

Understanding some code

Ok, let's get back to our light bulb action. Let's say we want to show the tool tip when the user is in the context of a method, so we can show the action menu and allow s/he to create a class out of it.

To begin, add the following NuGet packages to the project:

  • Microsoft.CodeAnalysis.CSharp.Workspaces
  • Microsoft.VisualStudio.LanguageServices (if you are splitting your code, this goes to the Visual Studio VSIX project only)
  • Microsoft.CodeAnalysis.EditorFeatures.Text (if you are splitting your code, this goes to the Visual Studio VSIX project only)

Let's create a class to help out with analyzing the code. I called it CodeProvider for lack of creativity.

using System.Linq;  
using System.Threading;  
using System.Threading.Tasks;  
using Microsoft.CodeAnalysis;  
using Microsoft.CodeAnalysis.CSharp.Syntax;  
using Microsoft.CodeAnalysis.Text;

namespace Sample  
{
    public class CodeProvider
    {
        private Workspace workspace;

        public CodeProvider(Workspace workspace)
        {
            this.workspace = workspace;
        }

        public async Task<IMethodSymbol> Analyze(Document document, int tokenPosition, CancellationToken cancellationToken)
        {
            var semanticModel = await document.GetSemanticModelAsync(cancellationToken);
            var syntaxTree = await semanticModel.SyntaxTree.GetRootAsync(cancellationToken);

            var token = syntaxTree.FindToken(tokenPosition);

            if (token != null && token.Parent != null)
            {
                foreach (var node in token.Parent.AncestorsAndSelf())
                {
                    Type nodeType = node.GetType();
                    if (nodeType == typeof(MethodDeclarationSyntax))
                    {
                        return (IMethodSymbol)semanticModel.GetDeclaredSymbol(node);
                    }
                    else if (nodeType == typeof(BlockSyntax))
                    {
                        // a block comes after the method declaration, the cursor is inside the block
                        // not what we want
                        return null;
                    }
                }
            }

            return null;
        }
    }
}

Ignore the constructor for a bit, it will come into picture later. The CanRefactor method is the one that we will make the ActionSource call to check whether to show the light bulb. It works as follows:

  1. We get the semantic model and syntax tree associated with the document (file).
  2. We get the token closest to the tokenPosition (that is, where the input cursor or text highlight is).
  3. If that token's parent, which is a syntax node, or any of its ancestors, is of type MethodDeclarationSyntax then we know the cursor is over the signature of a method. If the cursor is before the method signature, then there will be no MethodDeclarationSyntax node in the ancestor list (unless the method is inside another method - let's ignore it for now). If the method is inside the method body, then we have the BlockSyntax check to handle that scenario and consider that not to be a trigger for our light bulb.
  4. Next, we get the declared symbol from the semantic model associated with our syntax node. The declared symbol provides a higher level of knowledge for our declaration syntax node.

With this, we are saying, if the cursor is over the signature of a method, we can do some refactoring.

Note the CancellationToken usage. You are now a extension developer, you owe your users an extension that is fast, doesn't hang the IDE on every key stroke and that can stop doing something when the user clicks the cancel button!

Let's hook this up with our action source:

[Export(typeof(ISuggestedActionsSourceProvider))]
[Name("Test Suggested Actions")]
[ContentType("code")]
public class ActionsSourceProvider : ISuggestedActionsSourceProvider  
{
    private CodeProvider codeProvider;

    [ImportingConstructor]        
    public ActionsSourceProvider([Import(typeof(VisualStudioWorkspace), AllowDefault = true)] Workspace workspace)
    {
        this.codeProvider = new CodeProvider(workspace);
    }

    public ISuggestedActionsSource CreateSuggestedActionsSource(ITextView textView, ITextBuffer textBuffer)
    {
        return new ActionSource(this.codeProvider);
    }
}

public class ActionSource : ISuggestedActionsSource  
{
    public event EventHandler<EventArgs> SuggestedActionsChanged;
    private CodeProvider codeProvider;

    public ActionSource(CodeProvider codeProvider)
    {
        this.codeProvider = codeProvider;
    }

    public void Dispose()
    {
    }

    public IEnumerable<SuggestedActionSet> GetSuggestedActions(ISuggestedActionCategorySet requestedActionCategories, SnapshotSpan range, CancellationToken cancellationToken)
    {
        return new[] { new SuggestedActionSet(new[] { new Action() }) };
    }

    public async Task<bool> HasSuggestedActionsAsync(ISuggestedActionCategorySet requestedActionCategories, SnapshotSpan range, CancellationToken cancellationToken)
    {
        var document = range.Snapshot.TextBuffer.GetRelatedDocuments().FirstOrDefault();
        return document != null
            ? await this.codeProvider.Analyze(document, range.Start, cancellationToken) != null
            : false;
    }

    public bool TryGetTelemetryId(out Guid telemetryId)
    {
        telemetryId = default(Guid);
        return false;
    }
}

Note the changes to the constructor of ActionsSourceProvider. Since that class has an Export attribute, it can also request implementation of certain types to be provided to it when it is constructed. That's how we get a reference to the Workspace type. In our case, that reference is actually implemented by the class VisualStudioWorkspace, which is specific to Visual Studio. Since our CodeProvider only cares about the base class Workspace that code can still live outside the VSIX project, if you so desire. Soon enough we are going to put the workspace to work!

If you test it know, you should be able to see that the light bulb with our action only appears if you have a method signature highlighted or the cursor in any of the tokens that compose the signature itself.

Learning about the different types of SyntaxNodes and ISymbol comes with time, I guess. The Syntax Visualizer tool may help you. I found the source code to be helpful as well.

Generating some code

I've updated the CodeProvider to add a method to create or update a file in the project.

using System;  
using System.Linq;  
using System.Threading;  
using System.Threading.Tasks;  
using Microsoft.CodeAnalysis;  
using Microsoft.CodeAnalysis.CSharp.Syntax;  
using Microsoft.CodeAnalysis.Text;

namespace Sample  
{
    public class CodeProvider
    {
        private Workspace workspace;

        public CodeProvider(Workspace workspace)
        {
            this.workspace = workspace;
        }

        public void CreateOrUpdateDocument(string documentName, string documentContent)
        {
            var project = this.workspace.CurrentSolution.Projects.First();
            var document = project.Documents.FirstOrDefault(d => d.Name == documentName);
            var text = SourceText.From(documentContent);

            if (document == null)
            {
                document = project.AddDocument(documentName, text);
            }
            else
            {
                document = document.WithText(text);
            }

            workspace.TryApplyChanges(document.Project.Solution);
        }

        public async Task<IMethodSymbol> Analyze(Document document, int tokenPosition, CancellationToken cancellationToken)
        {
            var semanticModel = await document.GetSemanticModelAsync(cancellationToken);
            var syntaxTree = await semanticModel.SyntaxTree.GetRootAsync(cancellationToken);

            var token = syntaxTree.FindToken(tokenPosition);

            if (token != null && token.Parent != null)
            {
                foreach (var node in token.Parent.AncestorsAndSelf())
                {
                    Type nodeType = node.GetType();
                    if (nodeType == typeof(MethodDeclarationSyntax))
                    {
                        return (IMethodSymbol)semanticModel.GetDeclaredSymbol(node);
                    }
                    else if (nodeType == typeof(BlockSyntax))
                    {
                        // a block comes after the method declaration, the cursor is inside the block
                        // not what we want
                        return null;
                    }
                }
            }

            return null;
        }
    }
}

The logic is very simple. We take a document name and content string. We iterate over existing project items and create a new one if it doesn't exist or update an existing one.

Roslyn is actually much more powerful than that. You can change documents inline, for instance, with the DocumentEditor. Now you have the basic building blocks to expand it further.

And here is the uptake on the Action and ActionSource.

using System;  
using System.Collections.Generic;  
using System.ComponentModel.Composition;  
using System.Linq;  
using System.Threading;  
using System.Threading.Tasks;  
using Microsoft.CodeAnalysis;  
using Microsoft.CodeAnalysis.Text;  
using Microsoft.VisualStudio.Imaging.Interop;  
using Microsoft.VisualStudio.Language.Intellisense;  
using Microsoft.VisualStudio.LanguageServices;  
using Microsoft.VisualStudio.Text;  
using Microsoft.VisualStudio.Text.Editor;  
using Microsoft.VisualStudio.Utilities;  
using Sample;

namespace BlogSample  
{
    [Export(typeof(ISuggestedActionsSourceProvider))]
    [Name("Test Suggested Actions")]
    [ContentType("code")]
    public class ActionsSourceProvider : ISuggestedActionsSourceProvider
    {
        private CodeProvider codeProvider;

        [ImportingConstructor]        
        public ActionsSourceProvider([Import(typeof(VisualStudioWorkspace), AllowDefault = true)] Workspace workspace)
        {
            this.codeProvider = new CodeProvider(workspace);
        }

        public ISuggestedActionsSource CreateSuggestedActionsSource(ITextView textView, ITextBuffer textBuffer)
        {
            return new ActionSource(this.codeProvider);
        }
    }

    public class ActionSource : ISuggestedActionsSource
    {
        public event EventHandler<EventArgs> SuggestedActionsChanged;
        private CodeProvider codeProvider;

        public ActionSource(CodeProvider codeProvider)
        {
            this.codeProvider = codeProvider;
        }

        public void Dispose()
        {
        }

        public IEnumerable<SuggestedActionSet> GetSuggestedActions(ISuggestedActionCategorySet requestedActionCategories, SnapshotSpan range, CancellationToken cancellationToken)
        {
            var action = new Action(this.codeProvider, range);

            List<Action> actions = new List<Action>();

            if (action.HasSuggestedActionsAsync(cancellationToken).Result)
            {
                actions.Add(action);
            }

            return new[] { new SuggestedActionSet(actions) };
        }

        public Task<bool> HasSuggestedActionsAsync(ISuggestedActionCategorySet requestedActionCategories, SnapshotSpan range, CancellationToken cancellationToken)
        {
            return new Action(this.codeProvider, range).HasSuggestedActionsAsync(cancellationToken);
        }

        public bool TryGetTelemetryId(out Guid telemetryId)
        {
            telemetryId = default(Guid);
            return false;
        }
    }

    public class Action : ISuggestedAction
    {
        private CodeProvider codeProvider;
        private SnapshotSpan range;
        private Document document;
        private IMethodSymbol methodSymbol;

        public bool HasActionSets => false;

        public string DisplayText => "Do some code magic";

        public object IconMoniker => string.Empty;

        public string IconAutomationText => string.Empty;

        public string InputGestureText => string.Empty;

        public bool HasPreview => false;

        ImageMoniker ISuggestedAction.IconMoniker => default(ImageMoniker);

        public Action(CodeProvider codeProvider, SnapshotSpan range)
        {
            this.codeProvider = codeProvider;
            this.range = range;
        }

        public void Dispose()
        {
        }

        public Task<IEnumerable<SuggestedActionSet>> GetActionSetsAsync(CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public Task<object> GetPreviewAsync(CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }

        public async Task<bool> HasSuggestedActionsAsync(CancellationToken cancellationToken)
        {
            if (this.document == null)
            {
                this.document = this.range.Snapshot.TextBuffer.GetRelatedDocuments().FirstOrDefault();
                this.methodSymbol = this.document != null
                    ? await this.codeProvider.Analyze(document, range.Start, cancellationToken)
                    : null;
            }

            return this.methodSymbol != null;                
        }

        public void Invoke(CancellationToken cancellationToken)
        {
            if (this.HasSuggestedActionsAsync(cancellationToken).Result)
            {
                this.codeProvider.CreateOrUpdateDocument(this.methodSymbol.Name + ".cs", $"Created at { DateTime.Now.ToString() }");
            }
        }

        public bool TryGetTelemetryId(out Guid telemetryId)
        {
            telemetryId = default(Guid);
            return false;
        }
    }
}

Note that I refactored how I implemented the ActionSource.HasSuggestedActionsAsync. This is because Visual Studio some times calls GetSuggestedActions before checking if we want to display our action on the tool tip, and other times, it doesn't call HasSuggestedActionsAsync at all. In those cases, it decides whether to show the tool tip if there is an action in the returned collection by GetSuggestedActions. Not a great contract, but the one we have to work with.

The idea them is to minimize the costly call to CodeProvider.Analyze. To do that, we make the creation of Action cheap, and move the call there to check whether there is anything to be done or not. As part of that, the action will keep the result of the analysis, so it doesn't need to do it again, if the user decides to pick it. Note that you cannot save any state in the ActionSource because Visual Studio will reuse the same provider for many light bulb action queries.