Compiling C# Code At Runtime Made Easy

by Kenji Elzerman
Compile C# Code At Runtime - Kens Learning Curve

Besides writing a lot of tutorials for you, I also develop software. I recently started this project where a user can input C# code through a web application, and the C# code needs to grab that code, compile it, and run it. The results are sent back to the user. Compiling C# code at runtime isn’t new, but it can be rather complex because you have to take care of some uncertainties.

Note: This article is about compiling C# code, not running it. That is a story for another time. The information I give you about compiling is a lot, so I decided to split the article into two articles.

Goals

After this article you:

  • Know what at runtime means
  • Understand the idea behind compiling code at runtime
  • Are able to validate C# code from a string
  • Know how to add references and usings after an error from the compiled result
  • Read and write the errors after compilation

Preparation

In this article, I will show you a lot of code, which is why you are here. Since I don’t want to ‘pollute’ the article with a lot of front-end code and examples, I will stick to a console application and a business layer.

This way I can show you how you can create methods that accept the code and compile it. After this article, you know how the basics work and you can implement them in the applications you want.

If you want to type along, create a console application and name it CompileAndRun.Console. Name the solution CompileAndRun. Then create a class library and call it CompileAndRun.Business.

That’s it… For now.

Console Application

To understand what the compiler needs to do, I want to show you how I create my console application.

It will ask a simple question where the user is asked to write a single line of code. My Program.cs of the console looks like this:

string exampleCode = @"
public class Users
{
    private List<User> users = new()
    {
        new() { Id = 1, Name = ""Dwayne Johnson""},
        new() { Id = 2, Name = ""Keanu Reeves""},
        new() { Id = 3, Name = ""Will Smith""},
    };

    public User GetById(int id)
    {
        #CODE HERE#

        return users.Single(x => x.Id == id);
    }
}

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }    
}";
Console.WriteLine("Let's say you have the following code:");
Console.WriteLine(exampleCode);

Console.WriteLine(string.Empty);
Console.WriteLine("What line of code would you place on #CODE HERE# to throw an argument exception when the id is 0 or less?");

Console.Write("> ");

string input = Console.ReadLine();

string fullcode = exampleCode.Replace("#CODE HERE#", input);

Yes, I could make it a lot fancier, but let’s keep it simple since the topic is compiling C# code, not ‘the best way to create a console application’.

The code shows a piece of C# code as a string, The #CODE HERE# indicates the user has to write a line of code that should be placed here.

The console application allows the user to enter a line of code that will be stored in the variable input. That input is placed on the location of #CODE HERE# after the user has finished typing and pressed enter. Compiling C# code at runtime is done in the business layer, so we are done here for now.

Runtime

Before I start to explain to you all the cool stuff you can do, let me explain the definition of runtime. For most of us it is pretty clear what it is, but for those who don’t know it, here is some background.

When you are developing an application we have multiple phases. One of them is ‘runtime’. This means that your application is actually running. Whether it is in debug mode, release mode, or on the client’s computer.

Runtime also means you can’t change the code because it’s already compiled into a computer language done by the CLR (Common Language Runtime). This component is responsible for a lot. It handles memory management, security, type safety, execution environment, and much more. I think it’s safe to say the CLR is pretty important.

One of the tasks we can use the CLR for is compiling C# code. When you build your solution (or just a project) in Visual Studio, you use the CLR. This is all done by the IDE, Visual Studio for example. However, we can also use the CLR when we are running the application, which is in the runtime state.

Validating Code

We need some input before we can start compiling C# code at runtime. The console application asks a question that the user needs to answer with a piece of code. Then I want a service class, in the business layer, to compile it, and send back if the code is correct or not.

For this, I will create a new class in the CompileAndRun.Business and call it ValidationService. This service will handle the validation of the code the user enters in the console application. Within this class, I’ll create a void method called Execute. It has one parameter: string code.

namespace CompileAndRun.Business;

public class ValidationService
{
    public void Execute(string code)
    {

    }
}

Paths, Names, And Default Options

I don’t want to write the code to a DLL or something, so I need to make up some names and locations. Although we are going to write everything to the memory of your machine, we still need to tell the compiler where to store it. Compiling C# code into memory means that when we close the application, the information is gone.

To make this work we will use the location of object and a random file name.

public void Execute(string code)
{
    string assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
    string assemblyName = Path.GetRandomFileName();

}

The contents of the assemblyPath on my machine is C:\Program Files\dotnet\shared\Microsoft.NETCore.App\7.0.9. The assemblyName is sv0hag4s.sl4, but this is random.

The assemblyPath is a path on my machine where the .NET libraries can be found. This will be used later when we are going to gather and link the references.

Compilation Options

Next up: CSharpCompilationOptions. Here you can set some default options. I am going to use two: WithOverflowChecks and WithOptimimzationLevel.

The first one, the WithOverflowChecks… I have no idea. Strangely enough, I can’t find anything about it on the Internet. Even ChatGPT (my last resort) has no idea. If I should use common sense, and the little information I did find, it has to do with integrity checks on the code.

The WithOptimimzationLevel is a bit easier. It’s the debug or release version of your compilation. In contrast with the WithOverflowChecks, the parameter gives you a lot of information about the differences between a compilation made by debug or release.

public void Execute(string code)
{
    string assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
    string assemblyName = Path.GetRandomFileName();

    CSharpCompilationOptions defaultCompilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
        .WithOverflowChecks(true)
        .WithOptimizationLevel(OptimizationLevel.Release);
}

If the CSharpCompilationOptions can’t be found, make sure to install the package Microsoft.CodeAnalysis.CSharp.

References

The last thing we need to do before we can compile the string is to define some references.

But, which DLL do you need to use? Google (or Bing, DuckDuckGo, Yandex, whatever) will help you. I will tell you that the mscorlib.dll contains most namespaces. And to be sure, I’ll also include the System.dll, System.Runtime.dll, and the System.Core.dll.

Adding a reference is not just pointing out to a file; you need to create a list of the type MetadataReference. And we are going to create references by reading them from files: The DLLs found in the assemblyPath. And it looks like this:

public void Execute(string code)
{
    string assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
    string assemblyName = Path.GetRandomFileName();

    CSharpCompilationOptions defaultCompilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
        .WithOverflowChecks(true)
        .WithOptimizationLevel(OptimizationLevel.Release);

    IEnumerable<MetadataReference> defaultReferences = new[]
    {
        MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "mscorlib.dll")),
        MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.dll")),
        MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Core.dll")),
        MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll"))
    };
}

While compiling C# code at runtime, our application will assemble everything we need and reference them to the compiled code.

Create The Compilation

Time to put all the ingredients in a bowl and stir it:

CSharpCompilation compilation = CSharpCompilation.Create(
        assemblyName,
        syntaxTrees: new[] {
            CSharpSyntaxTree.ParseText(code)
        },
        references: defaultReferences,
        options: defaultCompilationOptions);

The CSharpCompilation.Create will create the C# compilation with the parameters shown in the code example. It first takes the assembly name, which is the random name we created at the start.

The syntaxTrees is an array of codes, in this case, one string, which we transform to a SyntaxTree by using the CSharpSyntaxTree.ParseText.

We link the defaultReferences and the defaultCompilationOptions to it and done! But we now have a compilation in a variable and it doesn’t do anything. Let’s write it to a stream.

Emitting To A Stream

We can take the compilation variable and place it in a stream. That will be compiling C# code, not executing it. The result could give us build errors, which is good to know before you want to execute the code.

Since I don’t want to write to a physical DLL file, I will write the code to a MemoryStream. After the initialization of that stream, I will emit the compilation into the stream. This will give us an EmitResult, which can indicate a success or not.

using var ms = new MemoryStream();
EmitResult result = compilation.Emit(ms);

The variable result (of type EmitResult) contains a property Success. When this is true we don’t have to do anything. If it is false, we can find the errors.

These errors are stored in the result.Diagnostics and can contain a lot of information. We want the errors and warnings, so we use LINQ to filter out those subjects we want.

IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic =>
    diagnostic.IsWarningAsError ||
    diagnostic.Severity == DiagnosticSeverity.Error);

This gives us a list of errors and warnings. Compiling C# code at runtime and not showing the errors makes it harder to run after compilation (it will crash).

Let’s return those to the console application. This means we need to change the return type of the method Execute to IEnumerable<Diagnostic> and return the errors when the emit was unsuccessful.

The Full Code

Here is the code for the method Execute, including the class:

public class ValidationService
{
    public IEnumerable<Diagnostic> Execute(string code)
    {
        string assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
        string assemblyName = Path.GetRandomFileName();

        CSharpCompilationOptions defaultCompilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
            .WithOverflowChecks(true)
            .WithOptimizationLevel(OptimizationLevel.Release);

        IEnumerable<MetadataReference> defaultReferences = new[]
        {
            MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "mscorlib.dll")),
            MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.dll")),
            MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Core.dll")),
            MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll"))
        };

        CSharpCompilation compilation = CSharpCompilation.Create(
            assemblyName,
            syntaxTrees: new[] {
                CSharpSyntaxTree.ParseText(code)
            },
            references: defaultReferences,
            options: defaultCompilationOptions);

        using var ms = new MemoryStream();
        EmitResult result = compilation.Emit(ms);

        if (result.Success)
            return null;

        IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic =>
            diagnostic.IsWarningAsError ||
            diagnostic.Severity == DiagnosticSeverity.Error);

        return failures;
    }
}

Showing The Errors

After compiling the C# code at runtime, all we have to do now is make sure the code from the console application, the variable fullcode, is sent to the method Execute in the ValidationService. That one is easy:

ValidationService validationService = new();
IEnumerable<Diagnostic> result = validationService.Execute(fullcode);

If the result is null it means there are no errors and the code is compiled successfully. But if the result is not null we can iterate through the errors and write them on the screen.

if (result == null)
{
    Console.WriteLine("Well done!");
}
else
{
    foreach (Diagnostic diagnostic in result)
        Console.WriteLine(diagnostic.GetMessage());
}

Let’s run it. Just type in something, it doesn’t matter what. If all goes well, you will see something like this:

Errors While Running - Compiling C# Code  At Runtime - Kens Learning Curve

These are not errors due to what I have entered and tried to compile. These are errors that certain objects, libraries, or types are not found. We are missing some usings and references.

I could have solved this before running and showing you all the references you will need to compile the code. However, you wouldn’t learn to recognize the errors if you would get them when you make your own compilation code, outside this example.

Fixing Errors

Compiling C# code at runtime doesn’t work out of the box. There are always references or usings missing. Although there are very good solutions to fix this, I want to keep it simple and I will help you fix the errors shown in the console application.

System.Object not defined or imported

The first error is that the System.Object is not defined or imported. That one is easy. Everything in .NET starts with an object, so when the object object can’t be found, C# is getting annoyed. Just add the following line to the defaultReferences:

MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location)

This will solve a lot of errors. If you run the application and let your code compile C# code again. You will see the following error:

Errors While Running List Not Found - Compiling C# Code  At Runtime - Kens Learning Curve

List<> not found

It can’t find the List<>, which is located in the System.Collections.Generic namespace. We need to add the using to the code we are going to compile. This means you need to change the exampleCode in the console application:

string exampleCode = @"
using System.Collections.Generic;

public class Users
{
    private List<User> users = new()
    {
        new() { Id = 1, Name = ""Dwayne Johnson""},
        new() { Id = 2, Name = ""Keanu Reeves""},
        new() { Id = 3, Name = ""Will Smith""},
    };

    public User GetById(int id)
    {
        #CODE HERE#

        return users.Single(x => x.Id == id);
    }
}

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }    
}";

Does not contain a definition for ‘Single’

This one is hit by the List<User>. In the example code, we do a Single(x => x.Id == id) on the List<User>. Single is a member of LINQ, which we didn’t include.

LINQ isn’t included with the default references we added. That means we need to add the reference to the DLL and add the using in our example code.

LINQ is in the System.Linq.dll file, located in the assembly path.:

IEnumerable<MetadataReference> defaultReferences = new[]
{
    MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "mscorlib.dll")),
    MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.dll")),
    MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Core.dll")),
    MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll")),
    MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Linq.dll")),
    MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location),
};

And add the using in the example code:

string exampleCode = @"
using System.Collections.Generic;
using System.Linq;

public class Users
{
    private List<User> users = new()
    {
        new() { Id = 1, Name = ""Dwayne Johnson""},
        new() { Id = 2, Name = ""Keanu Reeves""},
        new() { Id = 3, Name = ""Will Smith""},
    };

    public User GetById(int id)
    {
        #CODE HERE#

        return users.Single(x => x.Id == id);
    }
}

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }    
}";

You will now only get those errors not related to references or usings. It gives a good error concerning what I entered:

Compile Errors - Compiling C# Code  At Runtime - Kens Learning Curve

ArgumentException Not Found

If you run the application again and give the correct answer (throw new ArgumentException();) it will tell you that it can’t find the ArgumentException namespace or type. Again, you are missing something in the references.

Simply add the using System to the usings of the example code. After compiling C# code at runtime, you will see all works well.

Final Run

All errors that are not related to the user input are now fixed. You can run the console app and play along with it. Compiling C# code at runtime should work without problems. It will show you this result:

Successful Compilation - Compiling C# Code  At Runtime - Kens Learning Curve

Conclusion On Compiling C# Code At Runtime

Compiling C# code at runtime isn’t that hard. But the hassle with the usings and references can be frustrating (I speak from experience). When you are building something big, where code needs to compile at runtime, make sure to create a way to use the references and usings dynamically; don’t reference everything you know that might be needed.

My example shows the code to compile in a string variable. But you can also store this code in a database, file, or somewhere else.

I highly suggest creating unit tests to check if the code you wrote in a string/text is really working. Fire different scenarios to see if the code in the string behaves as needed. Unit tests will be quicker and easier to maintain than running them in a console application.

In the next article, I am going to show you how we can put the code, after compiling the C# code at runtime, to work and print a selected user to the console application.

Table Of Contents
Kens Learning Curve