Using Roslyn C# Completion Service programmatically

Β· 1709 words Β· 9 minutes to read

I am involved in a few open source projects built around the Roslyn compiler. One of those projects is OmniSharp, which brings intellisense and C# language services to a number of editors out there, allowing them to provide for their users a rich C# code authoring experience.

Which actually brings me to the point of today’s post. Roslyn is a compiler-as-a-service that you can embed in your own app, and when you do that, you could reach into its C# language services (more specifically, CompletionService) and easily build your own C# intellisense engine.

However, this is not really documented, so I wanted to use this post to show you how you can get started with that.

CompletionService πŸ”—

Roslyn’s CompletionService wasn’t initially publicly available, and the only way to programmatically achieve an intellisense-like experience was to use the fairly limited Recommender service. CompletionService was eventually exposed publicly in Roslyn 1.3.0.

So for today’s exercise, let’s use the CompletionService, and there are a few things that need to be wired in to make it work.

For starters, we will need to reference 2 Roslyn Nuget packages, as shown below (our test csproj file):

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <LangVersion>latest</LangVersion>
    <TargetFramework>netcoreapp2.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="2.10.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" />
  </ItemGroup>

</Project>

Version 2.10.0 of Roslyn is at a time of writing the latest available one.

Code to complete πŸ”—

We need some code to test our completion features against. Imagine the following simple example, where the current cursor position is just after the Guid. part.

using System;

public class MyClass
{
    public static void MyMethod(int value)
    {
        Guid.
    }
}

So if we imagine user’s experience here, our put ourselves in the shoes of the user, we typed in Guid, pressed a . character and are now expecting all the relevant completions in this particular context.

In Visual Studio / Visual Studio Code (via OmniSharp) this completion list would show up automatically. In case it got closed, for example by pressing ESC key, you can always trigger it back by pressing ctrl+j (VS) or ctrl/cmd+space (VS Code).

Host services πŸ”—

OK, it’s time to make it work. To begin with, let’s wire in Roslyn’s MEF services, as they are required to be populated for C# language services to work correctly.

The easiest way is to just use the default set:

var host = MefHostServices.Create(MefHostServices.DefaultAssemblies);

The following assemblies are included in the default set:

  • “Microsoft.CodeAnalysis.Workspaces”,
  • “Microsoft.CodeAnalysis.CSharp.Workspaces”,
  • “Microsoft.CodeAnalysis.VisualBasic.Workspaces”,
  • “Microsoft.CodeAnalysis.Features”,
  • “Microsoft.CodeAnalysis.CSharp.Features”,
  • “Microsoft.CodeAnalysis.VisualBasic.Features”

If you have any extensions to Roslyn or custom 3rd party Roslyn features that you’d like to include, you’d need to pass those extra assemblies in manually.

Workspace, Project, Document πŸ”—

Once the MEF services are wired in, the next step is to create a workspace. In this demo, we will just use an AdHocWorkspace.

For more complicated scenarios, such as for example being able to provide full intellisense to a real-world solution or csproj project(s), you’d need to pull in Microsoft.CodeAnalysis.Workspaces.MSBuild and follow these great instructions by Dustin on how to get started. However, let’s not obfuscate this sample with the complexities related to dealing with MsBuild (like the fact that this MsBuild workspace currently wouldn’t work in .NET Core applications).

So for us the code will be quite simple:

var workspace = new AdhocWorkspace(host);

Once the workspace is there, we need to create a Document containing our code, and then a Project to which we will add our document (or documents, should we have more). Then, the project should be added to the workspace. Again, in the case of MsBuildWorkspace this could be done for your automatically by reading the project files and their structure.

In our example, things would look like this:

var code = @"using System;

public class MyClass
{
    public static void MyMethod(int value)
    {
        Guid.
    }
}";

var projectInfo = ProjectInfo.Create(ProjectId.CreateNewId(), VersionStamp.Create(), "MyProject", "MyProject", LanguageNames.CSharp).
   WithMetadataReferences(new[]
   { 
       MetadataReference.CreateFromFile(typeof(object).Assembly.Location)
   });
var project = workspace.AddProject(projectInfo);
var document = workspace.AddDocument(project.Id, "MyFile.cs", SourceText.From(code));

In order to create a Project, we first create a ProjectInfo, where we need to pass in things like project name, assembly name (in our case “MyProject”), language type, and a couple of other things relevant to the compilation - primarily the MetadataReferences needed for our code to build. In our case it’s really only the corlib, or in other words, the assembly of object.

A Project instance is created for us as a result of adding a ProjectInfo to the Workspace. Similarly, a Document instance is also created for us when we ask a given string-based code, to be added the Workspace, in the context of a given project ID.

Once we have the Document that’s part of the Workspace, we can start using the CompletionService. It has a factory method that we can use, and we just need to pass in the relevant position in code (for us Guid.) in order for the completions to start showing up:

// position is the last occurrence of "Guid." in our test code
// in real life scenarios the editor surface should inform us
// about the current cursor position
var position = code.LastIndexOf("Guid.") + 5;

var completionService = CompletionService.GetService(document);
var results = await completionService.GetCompletionsAsync(document, position);

At that point we get null if there are no completions that make sense to the compiler, or a nice list of completions items we can inspect and display to the user.

Completions items are grouped into different categories, represented by Tags. This can help the editor visualize which type of the completion item it is dealing with. These could be for example the symbol kind (field, method etc), the accessibility level (public, internal etc) or any other information (is it a language keyword? per perhaps a parameter in the given scope? etc).

Each completion item is given to us with a DisplayText, which should be used by the editor to present the suggestion to the user, and SortText which can be used for sorting, as well as some other additional useful metadata.

Each completion item originates from a certain CompletionProvider - because the completion service itself actually aggregates results from a set of those providers that are plugged into the MEF host. Some of them are very specialized, for example the OverrideCompletionProvider which allows providing completions when user types in override.

Since CompletionProvider is public, you can also implement your own - and completely customize the process of completions in Roslyn.

Anyway, for now, let’s just print out what we got from Roslyn in this particular demo setup. I am going to spit out all this stuff to the console.

foreach (var i in results.Items)
{
    Console.WriteLine(i.DisplayText);

    foreach (var prop in i.Properties)
    {
        Console.Write($"{prop.Key}:{prop.Value}  ");
    }

    Console.WriteLine();
    foreach (var tag in i.Tags)
    {
        Console.Write($"{tag}  ");
    }

    Console.WriteLine();
    Console.WriteLine();
}

If we run our program now, the output should look like this:

Empty
ContextPosition:166  SymbolKind:6  InsertionText:Empty  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:Empty
Field  Public

Equals
ContextPosition:166  SymbolKind:9  InsertionText:Equals  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:Equals
Method  Public

NewGuid
ContextPosition:166  SymbolKind:9  InsertionText:NewGuid  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:NewGuid
Method  Public

Parse
ContextPosition:166  SymbolKind:9  InsertionText:Parse  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:Parse
Method  Public

ParseExact
ContextPosition:166  SymbolKind:9  InsertionText:ParseExact  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:ParseExact
Method  Public

ReferenceEquals
ContextPosition:166  SymbolKind:9  InsertionText:ReferenceEquals  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:ReferenceEquals
Method  Public

TryParse
ContextPosition:166  SymbolKind:9  InsertionText:TryParse  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:TryParse
Method  Public

TryParseExact
ContextPosition:166  SymbolKind:9  InsertionText:TryParseExact  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:TryParseExact
Method  Public

Notice that we get all the relevant methods/properties/fields that we’d expect. Each completion item has the extra information in the tags. Additionally there is some extra metadata in the properties dictionary, like for example the type of the provider that was used to provide this completion (albeit in our case they all end up being from SymbolCompletionProvider).

Script-style completions πŸ”—

So far the approach relied on fully fledged C# code. This works for most scenarios, however for situations where you are not dealing with the full C# projects or code files but rather would like to provide completions to lightweight snippets, it would not be applicable.

Imagine we want to get the exact same completion result set as above, but already when the user simply typed in:

Guid.

So no class, no method, no usings, just straight up 5 characters. This is especially enticing for REPL-like experiences or small snippets of code embedded somewhere.

Turns out this level of completion can also be achieved with Roslyn, when we turn to its “script” mode.

Fundamentally, our code would be the same, we’d create the MEF host and workspace the same way. However, when creating the Project and the Document we need to tell the compiler to parse our code as the so called SourceCodeKind.Script. This is shown below:

var scriptCode = "Guid.N";

var compilationOptions = new CSharpCompilationOptions(
   OutputKind.DynamicallyLinkedLibrary,
   usings: new[] { "System" });
                   
var scriptProjectInfo = ProjectInfo.Create(ProjectId.CreateNewId(), VersionStamp.Create(), "Script", "Script", LanguageNames.CSharp, isSubmission: true)
   .WithMetadataReferences(new[] 
   { 
       MetadataReference.CreateFromFile(typeof(object).Assembly.Location) 
   })
   .WithCompilationOptions(compilationOptions);

var scriptProject = workspace.AddProject(scriptProjectInfo);
var scriptDocumentInfo = DocumentInfo.Create(
    DocumentId.CreateNewId(scriptProject.Id), "Script",
    sourceCodeKind: SourceCodeKind.Script,
    loader: TextLoader.From(TextAndVersion.Create(SourceText.From(scriptCode), VersionStamp.Create())));
var scriptDocument = workspace.AddDocument(scriptDocumentInfo);

// cursor position is at the end
var position = scriptCode.Length - 1;

var completionService = CompletionService.GetService(scriptDocument);
var results = await completionService.GetCompletionsAsync(scriptDocument, position);

The APIs that we call are mostly the same, with same small differences which are the following:

  • we need to build up a custom version of CSharpCompilationOptions, that we later pass into our ProjectInfo. This is necessary so that we set up a project with a global set of using statements. In our case it’s only System (so that the user doesn’t have to type System.Guid.), but it could be more to allow this import-free completion experience for a wider range of types.
  • when creating our Document, we set up an extra DocumentInfo. The relationship between them is the same as between Project and ProjectInfo. This is going to be needed to move away from the regular C# parser to the script one. It will allow us to process our syntactically relaxed code without running into the compiler errors that would otherwise block us.

And that’s it - the result here is exactly the same as before, except we managed to provide the completions using the low-ceremony, lightweight, single line C# input, which I think is quite impressive.

Summary πŸ”—

All the code used in this article is, as usually, available on Github.

I hope this was interesting, and maybe even a little bit useful. Let me know if you’d like to take this topic further or hear about other building blocks of Roslyn, especially in the area of C# language services.

About


Hi! I'm Filip W., a cloud architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github and on Mastodon.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP