A journey about the new interceptors of .NET 8

This blog post describes my journey writing a new interceptor for .NET 8. This is not meant as a general tutorial, even though I will showcase some of the code samples.

The use case

Before I go into great detail, let's examine what I want to achieve and why interceptors are the choice here. In bUnit, the unit testing library for Blazor components, we want to help our users in a certain scenario: Setting up stubs. Here a small example - let's call it MyTextField:

@inject IJSRuntime JSRuntime

<input type="text" @bind-Value="Value" />

@code {
    [Parameter] public string Value { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await JSRuntime.InvokeVoidAsync("myJsInteropFunction", DotNetObjectReference.Create(this));
        }
    }
}

The component has some JavaScript code. The user would have to set up the javascript interop to make that component work. But in a unit test, we don't want to test the JavaScript interop. We want to test the component. Of course, that is quite straightforward for your own components, but when it comes down to third-party components, it gets more complicated. Even more so, when updating the library and now, for no apparent reason, the test is broken. That's where stubs come into play. The most basic idea in bUnit is something like that: ComponentFactory.AddStub<MyTextField>(). That works - but retrieving the values of the stub is not that straightforward. Basically, there is a dictionary where you can retrieve the values. But that is not very convenient. So, we want to make it easier. The idea is to have a stub that imitates the component. So, we want to have something like that:

public class MyTextFieldStub : ComponentBase
{
    [Parameter] public string Value { get; set; }
}

So, we only imitate the public contract of the component. To keep it simple (and cover probably >80% of cases), we just grab all Parameters and CascadingParameters and make them available as properties. That's it. Now, we can write our test like that:

ComponentFactories.SomehowAddTheStub<MyTextField>(); // This is the magic we will discuss in this blog post
var cut = RenderComponent<ParentComponent>();

var stub = cut.FindComponent<MyTextFieldStub>();
stub.Instance.Value.Should().Be("Hello World");

No need for the user to create MyTextFieldStub in the first place. Until now, you might think: Aren't "normal" source generators enough here? Well, yes and no - yes, in the sense we could introduce an attribute so that the user only has to provide the skeleton as such:

[Stub(typeof(MyTextField))]
public partial class MyTextFieldStub : ComponentBase
{
}

And the generator would generate the rest. But not in the sense of usability. Creating that stub itself - two steps too much! So, let's discuss the following approach: The user should only give a starting point, and literally everything should be done via interceptors and generators. Our starting point will look like this:

ComponentFactories.AddGeneratedStub<MyTextField>();

That is "the command" to intercept and generate code. The generated code we already discussed a bit; what about the interceptors? Well, we want to intercept the AddGeneratedStub method. So, every time the user calls that method, we want to call another method instead. So, we literally replaced the method call with another method call. That isn't natively possible with source generators! Of course, interceptors have some heavy downsides:

  • They are in preview - and only work with .NET 8
  • The user has to add stuff to its csproj (more later) to make it work
  • We have to retrieve the file path, the line, and the column number of the method call to replace it - that can be very brittle

But let's see how we can make it work!

## The generator code Generators have a distinct surface:

[Generator]
public class StubGenerator : IIncrementalGenerator
{
	/// <inheritdoc/>
	public void Initialize(IncrementalGeneratorInitializationContext context)

Here is where all the magic is happening. The first thing, before we go into further detail: Whoever references the generator does it in the same way as he does, including analyzers. That means they are not part of the final assembly. So, every type or function we create has to be created by the generator and will be part of the output of the linked project! That is important to know. For starters, we want to have the AddGeneratedStub method.

context.RegisterPostInitializationOutput(
			ctx => ctx.AddSource("AddGeneratedStub.g.cs",
				@"namespace Bunit
{
    public static class ComponentFactoriesExtensions
	{
        /// <summary>
		/// Marks a component as a stub component so that a stub gets generated for it. The stub has the same name as the component, but with the suffix ""Stub"" added.
		/// </summary>
		/// <typeparam name=""TComponent"">The type of component to generate a stub for.</typeparam>
		/// <remarks>
		/// When <c>ComponentFactories.AddGeneratedStub&lt;MyButton&gt;()</c> is called, a stub component is generated for the component
		/// with the name <c>MyButtonStub</c>. The stub component is added to the <see cref=""ComponentFactoryCollection""/> and can be used.
		/// It can also be retrieved via `cut.FindComponent&lt;MyButtonStub&gt;()`.
		/// This call does the same as <c>ComponentFactories.Add&lt;MyButton, MyButtonStub&gt;()</c>.
		/// </remarks>
		public static ComponentFactoryCollection AddGeneratedStub<TComponent>(this ComponentFactoryCollection factories)
			where TComponent : Microsoft.AspNetCore.Components.IComponent
		{
			return factories.AddGeneratedStubInterceptor<TComponent>();
		}
	}
}"));

This will generate a file with the name AddGeneratedStub.g.cs that is part of the linked project. So, from the user's perspective, it is as if he would have written that code himself. Currently, interceptors don't have a native type in the BCL - so we have to create it as well later.

That out of the way, we can use the generator like this:

var classesToStub = context.SyntaxProvider
    .CreateSyntaxProvider(
        predicate: static (s, _) => s is InvocationExpressionSyntax,
        transform: static (ctx, _) => GetStubClassInfo(ctx))
    .Where(static m => m is not null)
    .Collect();

context.RegisterSourceOutput(
    classesToStub,
    static (spc, source) => Execute(source, spc));

Here, all the magic happens - we create a syntax provider that looks for InvocationExpressionSyntax nodes. That is the syntax node that represents a method call. We then transform that node into a StubClassInfo object. That object contains all the information we need to generate the stub class. We then collect all the StubClassInfo objects and register them as source output. The Execute method is then called for each StubClassInfo object. Let's have a look at the StubClassInfo object:

internal sealed class StubClassInfo
{
	public string StubClassName { get; set; }
	public string TargetTypeNamespace { get; set; }
	public string UniqueQualifier => $"{TargetTypeNamespace}.{StubClassName}";
	public ITypeSymbol TargetType { get; set; }
	public string Path { get; set; }
	public int Line { get; set; }
	public int Column { get; set; }
}

With that we can do the following:

private static void GenerateInterceptorCode(StubClassInfo stubbedComponentGroup, IEnumerable<StubClassInfo> stubClassGrouped, SourceProductionContext context)
	{
		// Generate the attribute
		const string attribute = @"namespace System.Runtime.CompilerServices
{
	[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
	sealed file class InterceptsLocationAttribute : Attribute
	{
		public InterceptsLocationAttribute(string filePath, int line, int column)
		{
			_ = filePath;
			_ = line;
			_ = column;
		}
	}
}";

		// Generate the interceptor
		var interceptorSource = new StringBuilder();
		interceptorSource.AppendLine(attribute);
		interceptorSource.AppendLine();
		interceptorSource.AppendLine("namespace Bunit");
		interceptorSource.AppendLine("{");
		interceptorSource.AppendLine($"\tstatic class Interceptor{stubbedComponentGroup.StubClassName}");
		interceptorSource.AppendLine("\t{");

		foreach (var hit in stubClassGrouped)
		{
			interceptorSource.AppendLine(
				$"\t\t[System.Runtime.CompilerServices.InterceptsLocationAttribute(\"{hit.Path}\", {hit.Line}, {hit.Column})]");
		}

		interceptorSource.AppendLine(
			"\t\tpublic static global::Bunit.ComponentFactoryCollection AddGeneratedStubInterceptor<TComponent>(this global::Bunit.ComponentFactoryCollection factories)");
		interceptorSource.AppendLine("\t\t\twhere TComponent : Microsoft.AspNetCore.Components.IComponent");
		interceptorSource.AppendLine("\t\t{");
		interceptorSource.AppendLine(
			$"\t\t\treturn factories.Add<global::{stubbedComponentGroup.TargetType.ToDisplayString()}, {stubbedComponentGroup.TargetTypeNamespace}.{stubbedComponentGroup.StubClassName}>();");
		interceptorSource.AppendLine("\t\t}");
		interceptorSource.AppendLine("\t}");
		interceptorSource.AppendLine("}");

		context.AddSource($"Interceptor{stubbedComponentGroup.StubClassName}.g.cs", interceptorSource.ToString());
	}

We do two things in one call here: We create a file-scoped class that represents the InterceptorAttribute - as said earlier, that attribute isn't part of the base class library. We do it file-scoped per component type for which we create a stub. It is just simpler to do so - of course, we could create a public one, but that might conflict with other interceptors the user installed. So this is the easiest we could do. The second part is building the interceptor code itself, with all occurrences of the AddGeneratedStub method replaced with the AddGeneratedStubInterceptor method. We also add the InterceptsLocationAttribute to the method so that the compiler knows where to replace the method call. That is important because we need to know the file path, the line, and the column number of the method call to replace it. We also define what the replaced call looks like - and the replaced call obviously has to have the same return type and amount of arguments and argument types.

Usage

So, how does the user use it? Well, he has to add the following to his csproj:

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

	<PropertyGroup>
		<TargetFramework>net8.0</TargetFramework>
		<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Bunit</InterceptorsPreviewNamespaces>

For now, the user would have to add the InterceptorsPreviewNamespaces property to his csproj. That is because interceptors are in preview. The "bUnit" part is where our generator/interceptor is living. But with that out of the way, the user can use the generator as described above. When you add the AddGeneratedStub method, the generator will kick in and generate the stub class. So you can directly write:

cut.FindComponent<MyTextFieldStub>();

And it might look something like that:

namespace Bunit.Web.Stub.Components;
internal partial class CounterComponentStub : Microsoft.AspNetCore.Components.ComponentBase
{
	[global::Microsoft.AspNetCore.Components.Parameter]
	public int Count { get; set; }

	[global::Microsoft.AspNetCore.Components.CascadingParameter(Name = "Cascading")]
	public int CascadingCount { get; set; }

	[global::Microsoft.AspNetCore.Components.Parameter(CaptureUnmatchedValues = true)]
	public System.Collections.Generic.Dictionary<string, object> UnmatchedValues { get; set; }
}

Conclusion

Interceptors are a very powerful tool. With that, we can do things that are not possible with source generators alone. But they have some downsides and overusing them might make your code so much more harder to read and maintain - so use it carefully and sparingly.

Resources

If you are interested in the whole code, you can find it here: https://github.com/bUnit-dev/bUnit/pull/1300 You will find also how to retrieve line numbers, file paths and so on.

1
An error has occurred. This application may no longer respond until reloaded. Reload x