Browse Source

Show CustomDebugInformation blob contents as row details

Selecting a row in the CustomDebugInformation table now previews its
Value blob beneath it, completing WPF row-details parity: state-machine
hoisted local scopes, compilation options, metadata references, and
tuple element names parse into typed sub-grid rows; source-link JSON
shows as text; everything else — unrecognized kinds, embedded source,
and malformed structured blobs — degrades to a hex dump, as in WPF.

The parser switches on the Kind GUID directly via KnownGuids; the
entry deliberately carries no decoded-kind state, leaving that to the
kind-column work that builds on this.

Assisted-by: Claude:claude-fable-5[1m]:Claude Code
pull/3759/head
Siegfried Pammer 4 weeks ago
parent
commit
70e131dccd
  1. 203
      ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs
  2. 124
      ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs
  3. 15
      ILSpy/Metadata/Helpers.cs

203
ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs

@ -0,0 +1,203 @@ @@ -0,0 +1,203 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Text;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.Decompiler.DebugInfo;
using ICSharpCode.Decompiler.Metadata;
using ILSpy.Metadata;
using ILSpy.Metadata.DebugTables;
using ILSpy.ViewModels;
using NUnit.Framework;
using CustomDebugInformationEntry = ILSpy.Metadata.DebugTables.CustomDebugInformationTableTreeNode.CustomDebugInformationEntry;
namespace ICSharpCode.ILSpy.Tests.Metadata;
[TestFixture]
public class CustomDebugInformationRowDetailsTests
{
const string SourceLinkJson = /*lang=json,strict*/ """{"documents":{"/src/*":"https://example.invalid/*"}}""";
static readonly Guid UnknownKindGuid = new Guid("1c46e7c9-0631-44eb-a78a-f8e8e1e0c0a1");
static readonly Guid ReferenceMvid = new Guid("8e2c021c-1f4e-4a40-a3a8-9785b2a99f9c");
static MetadataFile BuildPdbFixture()
{
// Synthesize a standalone portable PDB whose CustomDebugInformation table holds one
// row per row-details scenario: a text kind (Source Link), an unrecognized kind
// GUID, an embedded-source blob (opaque at this layer), and the four structured
// kinds that parse into typed rows. Parents use ascending MethodDef tokens so the
// (parent-sorted) table preserves this row order.
var builder = new MetadataBuilder();
AddRow(1, KnownGuids.SourceLink, Encoding.UTF8.GetBytes(SourceLinkJson));
AddRow(2, UnknownKindGuid, new byte[] { 0x01, 0x02, 0x03 });
AddRow(3, KnownGuids.EmbeddedSource, new byte[] { 0x00, 0x00, 0x00, 0x00, 0x41 });
AddRow(4, KnownGuids.StateMachineHoistedLocalScopes, HoistedScopesBlob((0, 10), (16, 32)));
AddRow(5, KnownGuids.CompilationOptions, NullTerminatedStrings("language", "C#", "version", "2"));
AddRow(6, KnownGuids.CompilationMetadataReferences, MetadataReferenceBlob(
"System.Runtime.dll", "global", flags: 1, timestamp: 0x12345678, fileSize: 1024, ReferenceMvid));
AddRow(7, KnownGuids.TupleElementNames, NullTerminatedStrings("Item1", "Name"));
var rowCounts = new int[MetadataTokens.TableCount];
rowCounts[(int)TableIndex.MethodDef] = 7;
var pdbBuilder = new PortablePdbBuilder(builder, rowCounts.ToImmutableArray(), entryPoint: default);
var blob = new BlobBuilder();
pdbBuilder.Serialize(blob);
var provider = MetadataReaderProvider.FromPortablePdbImage(blob.ToImmutableArray());
return new MetadataFile(MetadataFile.MetadataFileKind.ProgramDebugDatabase, "synthetic.pdb", provider);
void AddRow(int methodRow, Guid kind, byte[] value)
{
builder.AddCustomDebugInformation(
MetadataTokens.EntityHandle(TableIndex.MethodDef, methodRow),
builder.GetOrAddGuid(kind),
builder.GetOrAddBlob(value));
}
}
static byte[] HoistedScopesBlob(params (uint StartOffset, uint Length)[] scopes)
{
// State-machine hoisted local scopes: a sequence of (start offset, length) uint32 pairs.
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
foreach (var (start, length) in scopes)
{
writer.Write(start);
writer.Write(length);
}
writer.Flush();
return ms.ToArray();
}
static byte[] NullTerminatedStrings(params string[] values)
{
// Compilation options and tuple element names are flat sequences of null-terminated
// UTF-8 strings (options interpret them pairwise as name/value).
using var ms = new MemoryStream();
foreach (var value in values)
{
var bytes = Encoding.UTF8.GetBytes(value);
ms.Write(bytes, 0, bytes.Length);
ms.WriteByte(0);
}
return ms.ToArray();
}
static byte[] MetadataReferenceBlob(string fileName, string aliases, byte flags, uint timestamp, uint fileSize, Guid mvid)
{
using var ms = new MemoryStream();
var name = Encoding.UTF8.GetBytes(fileName);
ms.Write(name, 0, name.Length);
ms.WriteByte(0);
var alias = Encoding.UTF8.GetBytes(aliases);
ms.Write(alias, 0, alias.Length);
ms.WriteByte(0);
using var writer = new BinaryWriter(ms);
writer.Write(flags);
writer.Write(timestamp);
writer.Write(fileSize);
writer.Write(mvid.ToByteArray());
writer.Flush();
return ms.ToArray();
}
static List<CustomDebugInformationEntry> LoadEntries(MetadataFile metadataFile)
=> metadataFile.Metadata.CustomDebugInformation
.Select(h => new CustomDebugInformationEntry(metadataFile, h))
.ToList();
[Test]
public void RowDetails_Parses_The_Structured_Kinds_Into_Typed_Rows()
{
var metadataFile = BuildPdbFixture();
var entries = LoadEntries(metadataFile);
entries[3].RowDetails.Should().BeAssignableTo<IEnumerable<HoistedLocalScopeDetail>>()
.Which.Should().Equal(
new HoistedLocalScopeDetail(0, 10),
new HoistedLocalScopeDetail(16, 32));
entries[4].RowDetails.Should().BeAssignableTo<IEnumerable<CompilationOptionDetail>>()
.Which.Should().Equal(
new CompilationOptionDetail("language", "C#"),
new CompilationOptionDetail("version", "2"));
entries[5].RowDetails.Should().BeAssignableTo<IEnumerable<MetadataReferenceDetail>>()
.Which.Should().Equal(
new MetadataReferenceDetail("System.Runtime.dll", "global", 1, 0x12345678, 1024, ReferenceMvid));
entries[6].RowDetails.Should().BeAssignableTo<IEnumerable<TupleElementNameDetail>>()
.Which.Should().Equal(
new TupleElementNameDetail("Item1"),
new TupleElementNameDetail("Name"));
}
[Test]
public void RowDetails_Shows_Text_For_Source_Link_And_Hex_For_Opaque_Blobs()
{
var metadataFile = BuildPdbFixture();
var entries = LoadEntries(metadataFile);
entries[0].RowDetails.Should().Be(SourceLinkJson, "source link blobs are UTF-8 JSON");
entries[1].RowDetails.Should().Be("01-02-03", "unrecognized kinds degrade to a hex dump");
entries[2].RowDetails.Should().Be("00-00-00-00-41",
"embedded source is an opaque payload to the row-details parser and dumps as hex");
}
[AvaloniaTest]
public void Tab_Configures_Selection_Driven_Row_Details_That_Route_By_Blob_Shape()
{
// The details area swaps presentation per row: text blobs land in a read-only
// TextBox, structured kinds in a sub-grid. Selection drives visibility, so scanning
// the table with the arrow keys previews each blob.
var metadataFile = BuildPdbFixture();
var node = new CustomDebugInformationTableTreeNode(metadataFile);
var tab = (MetadataTablePageModel)node.CreateTab();
tab.RowDetailsVisibilityMode.Should().Be(DataGridRowDetailsVisibilityMode.VisibleWhenSelected);
tab.RowDetailsTemplate.Should().NotBeNull();
var entries = tab.Items.Cast<CustomDebugInformationEntry>().ToList();
var shell = tab.RowDetailsTemplate!.Build(entries[0])
.Should().BeOfType<MetadataRowDetailsControl>().Subject;
shell.DataContext = entries[0];
shell.Content.Should().BeOfType<TextBox>().Which.Text.Should().Be(SourceLinkJson);
shell.DataContext = entries[4];
var optionsGrid = shell.Content.Should().BeOfType<DataGrid>().Subject;
((IEnumerable)optionsGrid.ItemsSource!).Cast<CompilationOptionDetail>().Should().HaveCount(2);
}
}

124
ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs

@ -16,14 +16,32 @@ @@ -16,14 +16,32 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using Avalonia.Controls;
using ICSharpCode.Decompiler.DebugInfo;
using ICSharpCode.Decompiler.Metadata;
using ILSpy.ViewModels;
namespace ILSpy.Metadata.DebugTables
{
/// <summary>One hoisted-local scope (IL offset range) of a state-machine method.</summary>
public sealed record HoistedLocalScopeDetail(uint StartOffset, uint Length);
/// <summary>One name/value pair from a compilation-options blob.</summary>
public sealed record CompilationOptionDetail(string Name, string Value);
/// <summary>One reference from a compilation-metadata-references blob.</summary>
public sealed record MetadataReferenceDetail(string FileName, string Aliases, byte Flags, uint Timestamp, uint FileSize, Guid Mvid);
/// <summary>One element name from a tuple-element-names blob.</summary>
public sealed record TupleElementNameDetail(string ElementName);
/// <summary>
/// View of the CustomDebugInformation table — extensible per-entity payloads used for
/// async/iterator state-machine info, embedded source, source-link JSON, and similar
@ -46,6 +64,37 @@ namespace ILSpy.Metadata.DebugTables @@ -46,6 +64,37 @@ namespace ILSpy.Metadata.DebugTables
return list;
}
protected override void ConfigurePage(MetadataTablePageModel page)
{
// Selecting a row previews its Value blob beneath it: structured kinds as a typed
// sub-grid, source-link JSON decoded, everything else as a hex dump.
page.RowDetailsVisibilityMode = DataGridRowDetailsVisibilityMode.VisibleWhenSelected;
page.RowDetailsTemplate = MetadataRowDetails.CreateTemplate(BuildRowDetailsContent);
}
static Control? BuildRowDetailsContent(object? item)
{
return (item as CustomDebugInformationEntry)?.RowDetails switch {
string text => MetadataRowDetails.BuildTextBlob(text),
IReadOnlyList<HoistedLocalScopeDetail> rows => MetadataRowDetails.BuildDetailsGrid(rows,
("Start Offset", nameof(HoistedLocalScopeDetail.StartOffset)),
("Length", nameof(HoistedLocalScopeDetail.Length))),
IReadOnlyList<CompilationOptionDetail> rows => MetadataRowDetails.BuildDetailsGrid(rows,
("Name", nameof(CompilationOptionDetail.Name)),
("Value", nameof(CompilationOptionDetail.Value))),
IReadOnlyList<MetadataReferenceDetail> rows => MetadataRowDetails.BuildDetailsGrid(rows,
("File Name", nameof(MetadataReferenceDetail.FileName)),
("Aliases", nameof(MetadataReferenceDetail.Aliases)),
("Flags", nameof(MetadataReferenceDetail.Flags)),
("Timestamp", nameof(MetadataReferenceDetail.Timestamp)),
("File Size", nameof(MetadataReferenceDetail.FileSize)),
("MVID", nameof(MetadataReferenceDetail.Mvid))),
IReadOnlyList<TupleElementNameDetail> rows => MetadataRowDetails.BuildDetailsGrid(rows,
("Element Name", nameof(TupleElementNameDetail.ElementName))),
_ => null,
};
}
public sealed class CustomDebugInformationEntry
{
readonly MetadataFile metadataFile;
@ -77,6 +126,81 @@ namespace ILSpy.Metadata.DebugTables @@ -77,6 +126,81 @@ namespace ILSpy.Metadata.DebugTables
}
}
object? rowDetails;
/// <summary>
/// Parsed view of the Value blob for the row-details area. Structured kinds become
/// typed row lists, source-link blobs the decoded JSON text, everything else
/// (including malformed structured blobs) a hex dump. Cached — the details area
/// re-requests it on every selection change.
/// </summary>
public object? RowDetails {
get {
if (rowDetails != null)
return rowDetails;
if (debugInfo.Value.IsNil || debugInfo.Kind.IsNil)
return null;
var reader = metadataFile.Metadata.GetBlobReader(debugInfo.Value);
try
{
return rowDetails = ParseRowDetails(ref reader);
}
catch (BadImageFormatException)
{
return rowDetails = metadataFile.Metadata.GetBlobReader(debugInfo.Value).ToHexString();
}
}
}
object ParseRowDetails(ref BlobReader reader)
{
var kind = metadataFile.Metadata.GetGuid(debugInfo.Kind);
if (kind == KnownGuids.StateMachineHoistedLocalScopes)
{
var list = new List<HoistedLocalScopeDetail>();
while (reader.RemainingBytes > 0)
list.Add(new HoistedLocalScopeDetail(reader.ReadUInt32(), reader.ReadUInt32()));
return list;
}
if (kind == KnownGuids.SourceLink)
return reader.ReadUTF8(reader.RemainingBytes);
if (kind == KnownGuids.CompilationOptions)
{
var list = new List<CompilationOptionDetail>();
while (reader.RemainingBytes > 0)
{
string name = reader.ReadUTF8StringNullTerminated();
string value = reader.ReadUTF8StringNullTerminated();
list.Add(new CompilationOptionDetail(name, value));
}
return list;
}
if (kind == KnownGuids.CompilationMetadataReferences)
{
var list = new List<MetadataReferenceDetail>();
while (reader.RemainingBytes > 0)
{
string fileName = reader.ReadUTF8StringNullTerminated();
string aliases = reader.ReadUTF8StringNullTerminated();
byte flags = reader.ReadByte();
uint timestamp = reader.ReadUInt32();
uint fileSize = reader.ReadUInt32();
Guid mvid = reader.ReadGuid();
list.Add(new MetadataReferenceDetail(fileName, aliases, flags, timestamp, fileSize, mvid));
}
return list;
}
if (kind == KnownGuids.TupleElementNames)
{
var list = new List<TupleElementNameDetail>();
while (reader.RemainingBytes > 0)
list.Add(new TupleElementNameDetail(reader.ReadUTF8StringNullTerminated()));
return list;
}
return reader.ToHexString();
}
public CustomDebugInformationEntry(MetadataFile metadataFile, CustomDebugInformationHandle handle)
{
this.metadataFile = metadataFile;

15
ILSpy/Metadata/Helpers.cs

@ -195,5 +195,20 @@ namespace ILSpy.Metadata @@ -195,5 +195,20 @@ namespace ILSpy.Metadata
public static int ComputeCodedTokenSize(this MetadataReader metadata, int largeRowSize, TableMask mask)
=> (int)computeCodedTokenSizeMethod!.Invoke(metadata, [largeRowSize, rowCountsField!.GetValue(metadata)!, (ulong)mask])!;
/// <summary>
/// Reads a null-terminated UTF-8 string and advances the reader past the terminator.
/// Throws <see cref="BadImageFormatException"/> when no terminator remains, so callers
/// parsing structured blobs can degrade to a hex dump on malformed input.
/// </summary>
public static string ReadUTF8StringNullTerminated(this ref BlobReader reader)
{
int length = reader.IndexOf(0);
if (length < 0)
throw new BadImageFormatException("Missing null terminator in blob.");
string s = reader.ReadUTF8(length);
reader.ReadByte();
return s;
}
}
}

Loading…
Cancel
Save