Files
VisualizerExtensionExample/NrxDebugVisualizer/README.md

197 lines
12 KiB
Markdown
Raw Normal View History

2026-03-31 11:41:40 +02:00
# RegEx Match Debug Visualizer
This VisualStudio.Extensibility extension adds two new debugger visualizers supporting the .NET [`Match`](https://learn.microsoft.com/dotnet/api/system.text.regularexpressions.match) and [`MatchCollection`](https://learn.microsoft.com/dotnet/api/system.text.regularexpressions.matchcollection) classes.
![RegEx Match visualizer](RegexMatchVisualizer.png "RegEx Match visualizer")
The extension is composed of two projects: `RegexMatchDebugVisualizer`, the actual extension, and `RegexMatchObjectSource`, the visualizer object source library.
## Creating the extension
The extension project is created as described in the [tutorial document](https://learn.microsoft.com/visualstudio/extensibility/visualstudio.extensibility/get-started/create-your-first-extension). You can also reference the [debugger visualizers guide](https://learn.microsoft.com/visualstudio/extensibility/visualstudio.extensibility/debugger-visualizer/debugger-visualizers) for additional information.
## The `Match` visualizer
The next step is to create a `DebuggerVisualizerProvider` class to visualize instances of [`Match`](https://learn.microsoft.com/dotnet/api/system.text.regularexpressions.match):
```csharp
[VisualStudioContribution]
internal class RegexMatchDebuggerVisualizerProvider : DebuggerVisualizerProvider
{
...
```
If the `Match` type were serializable by Newtonsoft.Json, the visualizer implementation would be extremely simple:
```csharp
public override DebuggerVisualizerProviderConfiguration DebuggerVisualizerProviderConfiguration => ew("Regex Match visualizer", typeof(Match));
public override async Task<IRemoteUserControl> CreateVisualizerAsync(VisualizerTarget isualizerTarget, CancellationToken cancellationToken)
{
var regexMatch = await visualizerTarget.ObjectSource.RequestDataAsync<Match>(jsonSerializer: null, cancellationToken);
return new RegexMatchVisualizerUserControl(regexMatch);
}
```
Unfortunately `Match` is not serializable as-is, so we need a new serializable class [`RegexMatch`](RegexMatchObjectSource/RegexMatch.cs). And we will need to create a visualizer object source library to convert the `Match` into a serializable `RegexMatch`, more about this in [a later paragraph](#the-visualizer-object-source). For now, let's just update the `RequestDataAsync` call to use `RegexMatch`:
```csharp
var regexMatch = await visualizerTarget.ObjectSource.RequestDataAsync<RegexMatch>(jsonSerializer: null, cancellationToken);
```
### Adding the remote user control
We now have to create the `RegexMatchVisualizerUserControl` [class](./RegexMatchDebugVisualizer/RegexMatch/RegexMatchVisualizerUserControl.cs) and its associated [XAML file](./RegexMatchDebugVisualizer/RegexMatch/RegexMatchVisualizerUserControl.xaml). This process is described in the [Remote UI documentation](https://learn.microsoft.com/visualstudio/extensibility/visualstudio.extensibility/inside-the-sdk/remote-ui).
Every time we create a remote user control like `RegexMatchVisualizerUserControl` we need to configure the corresponding XAML file as embedded resource. In this case, since the XAML file is in a subfolder, we also need to use `LogicalName` to make sure the name of the resource matches the full name of the remote user control class. This is all done in the `.csproj` file:
```xml
<ItemGroup>
<Page Remove="RegexMatchVisualizerUserControl.xaml" />
<EmbeddedResource Include="RegexMatch\RegexMatchVisualizerUserControl.xaml" LogicalName="$(RootNamespace).RegexMatchVisualizerUserControl.xaml" />
</ItemGroup>
```
## The visualizer object source
The visualizer object source assembly will be loaded by the debugger into the process being debugged and will take care of converting the `Match` object into a serializable `RegexMatch`.
I will start creating a `RegexMatchObjectSource` class library targeting `netstandard2.0` and adding a project reference to [Microsoft.VisualStudio.DebuggerVisualizers](https://www.nuget.org/packages/Microsoft.VisualStudio.DebuggerVisualizers) version 17.6 or newer. Targeting `netstandard2.0` will allow the debugger visualizer to easily work with a large variety of .NET versions.
I will then create a `RegexMatchObjectSource` class extending `VisualizerObjectSource` and will override the `GetData` method to convert the `target` from a `Match` to a `RegexMatch` and use the `VisualizerObjectSource.SerializeAsJson` method to write the value to the `outgoingData` stream.
```csharp
public class RegexMatchObjectSource : VisualizerObjectSource
{
public override void GetData(object target, Stream outgoingData)
{
if (target is Match match)
{
RegexMatch result = Convert(match);
SerializeAsJson(outgoingData, result);
}
}
...
```
The `GetData` method is invoked by the debugger when the `RegexMatchDebuggerVisualizerProvider` calls `RequestDataAsync`.
`SerializeAsJson` will serialize the `RegexMatch` object using Newtonsoft.Json, which is loaded by the debugger in the process being debugged via reflection. Since my visualizer object source doesn't need to refence Newtonsoft.Json directly, I didn't include a `PackageReference` to it, which is better since we should minimize the dependencies of the visualizer object source assembly. Because this code doesn't reference Newtonsoft.Json, the `RegexMatch` class uses `DataContract` and `DataMember` attributes to control serialization instead of the Newtonsoft.Json-specific types.
My [`RegexMatchObjectSource`](./RegexMatchObjectSource/RegexMatchObjectSource.cs) implementation contains a small trick: the [`Group.Name`](https://learn.microsoft.com/dotnet/api/system.text.regularexpressions.group.name) property is read through reflection since it's available on most .NET versions but it is not included in the `netstandard2.0` APIs:
```csharp
private static readonly Func<Group, string?>? GetGroupName =
(Func<Group, string?>?)typeof(Group).GetProperty("Name")?.GetGetMethod().CreateDelegate(typeof(Func<Group, string?>));
...
Name = $"[{GetGroupName?.Invoke(g) ?? i.ToString()}]"
```
### Referencing the visualizer object source from the extension
First, we need to make sure that the visualizer object source library is packaged as part of the extension. We can do that in the extension's `.csproj` file:
```xml
<ItemGroup>
<Content Include="..\..\..\..\bin\samples\RegexMatchObjectSource\$(Configuration)\netstandard2.0\RegexMatchObjectSource.dll" Link="netstandard2.0\RegexMatchObjectSource.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RegexMatchObjectSource\RegexMatchObjectSource.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
```
The `ProjectReference` guarantees that the visualizer object source library is built before the extension and the `Content` item makes sure that the visualizer object source DLL is copied into the `netstandard2.0` extension's subfolder where it will be discoverable by the debugger.
I have decided to use `ReferenceOutputAssembly="false"` to avoid a dependency of the extension assembly from the visualizer object source one. This allows using conditional compilation (`#if`) to have slightly different definitions of [`RegexCapture`](./RegexMatchObjectSource/RegexCapture.cs) in the two projects. Since I decided to avoid the dependency, I will need to:
1. link the `RegexMatch.cs` file (and the related `RegexCapture` and `RegexGroup` ones) so that they are available in both projects:
```xml
<ItemGroup>
<Compile Include="..\RegexMatchObjectSource\RegexGroup.cs" Link="SharedFiles\RegexGroup.cs" />
<Compile Include="..\RegexMatchObjectSource\RegexCapture.cs" Link="SharedFiles\RegexCapture.cs" />
<Compile Include="..\RegexMatchObjectSource\RegexMatch.cs" Link="SharedFiles\RegexMatch.cs" />
</ItemGroup>
```
2. Reference the `RegexMatchObjectSource` from the `DebuggerVisualizerProviderConfiguration` using its assembly-qualified name:
```csharp
public override DebuggerVisualizerProviderConfiguration DebuggerVisualizerProviderConfiguration => new("Regex Match visualizer", typeof(Match))
{
VisualizerObjectSourceType = new("Microsoft.VisualStudio.Gladstone.RegexMatchVisualizer.ObjectSource.RegexMatchObjectSource, RegexMatchObjectSource"),
};
```
In most cases, having the extension project depend on the visualizer object source library is simpler: I could have simplified the `DebuggerVisualizerProviderConfiguration` to:
```csharp
public override DebuggerVisualizerProviderConfiguration DebuggerVisualizerProviderConfiguration => new("Regex Match visualizer", typeof(Match))
{
VisualizerObjectSourceType = new(typeof(RegexMatchObjectSource)),
};
```
## The `MatchCollection` visualizer
Now that the `Match` visualizer is complete, we can add a second visualizer for the [`MatchCollection`](https://learn.microsoft.com/dotnet/api/system.text.regularexpressions.matchcollection) class. The process is exactly the same: create a new [`DebuggerVisualizerProvider`](./RegexMatchDebugVisualizer/RegexMatchCollection/RegexMatchCollectionDebuggerVisualizerProvider.cs) and its [remote user control](./RegexMatchDebugVisualizer/RegexMatchCollection/RegexMatchCollectionVisualizerUserControl.cs). Also, add a new [`VisualizerObjectSource`](./RegexMatchObjectSource/RegexMatchCollectionObjectSource.cs) to the visualizer object source library.
Each call to `RequestDataAsync` is allowed only 5 seconds to complete before throwing a timeout exception. Since the `MatchCollection` could contain many entries, the visualizer object source uses the `TransferData` method instead of `GetData`: `TransferData` accepts a parameter which allows the visualizer to query the collection entries one by one:
```csharp
public override void TransferData(object target, Stream incomingData, Stream outgoingData)
{
var index = (int)DeserializeFromJson(incomingData, typeof(int))!;
if (target is MatchCollection matchCollection && index < matchCollection.Count)
{
var result = RegexMatchObjectSource.Convert(matchCollection[index]);
result.Name = $"[{index}]";
SerializeAsJson(outgoingData, result);
}
else
{
SerializeAsJson(outgoingData, null);
}
}
```
Instead of using the `VisualizerTarget` directly, the `DebuggerVisualizerProvider` passes it to the remote user control so that it can asynchronously request the collection entries without delaying the display of the visualizer UI to the user.
```csharp
public override Task<IRemoteUserControl> CreateVisualizerAsync(VisualizerTarget visualizerTarget, CancellationToken cancellationToken)
{
return Task.FromResult<IRemoteUserControl>(new RegexMatchCollectionVisualizerUserControl(visualizerTarget));
}
```
The remote user control uses the `RequestDataAsync` override that takes a `message` parameter, which results in `TransferData` being invoked on the visualizer object source. The remote user control will loop, invoking `RequestDataAsync` for increasing index numbers until the visualizer object source returns `null`, which indicates the end of the collection:
```csharp
public override Task ControlLoadedAsync(CancellationToken cancellationToken)
{
_ = Task.Run(async () =>
{
for (int i = 0; ; i++)
{
RegexMatch? regexMatch = await this.visualizerTarget.ObjectSource.RequestDataAsync<int, RegexMatch?>(message: i, jsonSerializer: null, CancellationToken.None);
if (regexMatch is null)
{
break;
}
this.RegexMatches.Add(regexMatch);
}
});
return Task.CompletedTask;
}
```
This is a very simple implementation of a debugger visualizer which relies on `RequestDataAsync`. More complex implementations may pass more complex parameters to `RequestDataAsync` in order to retrieve different information from the visualizer object source. You could even invoke `RequestDataAsync` in response to the user's interactions with the remote user control, allowing the user to "explore" the content of, potentially very large, objects.