diff --git a/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs b/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs new file mode 100644 index 000000000..6a2db951b --- /dev/null +++ b/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs @@ -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 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>() + .Which.Should().Equal( + new HoistedLocalScopeDetail(0, 10), + new HoistedLocalScopeDetail(16, 32)); + + entries[4].RowDetails.Should().BeAssignableTo>() + .Which.Should().Equal( + new CompilationOptionDetail("language", "C#"), + new CompilationOptionDetail("version", "2")); + + entries[5].RowDetails.Should().BeAssignableTo>() + .Which.Should().Equal( + new MetadataReferenceDetail("System.Runtime.dll", "global", 1, 0x12345678, 1024, ReferenceMvid)); + + entries[6].RowDetails.Should().BeAssignableTo>() + .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().ToList(); + var shell = tab.RowDetailsTemplate!.Build(entries[0]) + .Should().BeOfType().Subject; + + shell.DataContext = entries[0]; + shell.Content.Should().BeOfType().Which.Text.Should().Be(SourceLinkJson); + + shell.DataContext = entries[4]; + var optionsGrid = shell.Content.Should().BeOfType().Subject; + ((IEnumerable)optionsGrid.ItemsSource!).Cast().Should().HaveCount(2); + } +} diff --git a/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs b/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs index 48b7f4d8a..70bed1137 100644 --- a/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs +++ b/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs @@ -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 { + /// One hoisted-local scope (IL offset range) of a state-machine method. + public sealed record HoistedLocalScopeDetail(uint StartOffset, uint Length); + + /// One name/value pair from a compilation-options blob. + public sealed record CompilationOptionDetail(string Name, string Value); + + /// One reference from a compilation-metadata-references blob. + public sealed record MetadataReferenceDetail(string FileName, string Aliases, byte Flags, uint Timestamp, uint FileSize, Guid Mvid); + + /// One element name from a tuple-element-names blob. + public sealed record TupleElementNameDetail(string ElementName); + /// /// 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 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 rows => MetadataRowDetails.BuildDetailsGrid(rows, + ("Start Offset", nameof(HoistedLocalScopeDetail.StartOffset)), + ("Length", nameof(HoistedLocalScopeDetail.Length))), + IReadOnlyList rows => MetadataRowDetails.BuildDetailsGrid(rows, + ("Name", nameof(CompilationOptionDetail.Name)), + ("Value", nameof(CompilationOptionDetail.Value))), + IReadOnlyList 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 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 } } + object? rowDetails; + + /// + /// 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. + /// + 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(); + 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(); + 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(); + 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(); + while (reader.RemainingBytes > 0) + list.Add(new TupleElementNameDetail(reader.ReadUTF8StringNullTerminated())); + return list; + } + return reader.ToHexString(); + } + public CustomDebugInformationEntry(MetadataFile metadataFile, CustomDebugInformationHandle handle) { this.metadataFile = metadataFile; diff --git a/ILSpy/Metadata/Helpers.cs b/ILSpy/Metadata/Helpers.cs index 1fb09a307..4bdc3df67 100644 --- a/ILSpy/Metadata/Helpers.cs +++ b/ILSpy/Metadata/Helpers.cs @@ -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])!; + + /// + /// Reads a null-terminated UTF-8 string and advances the reader past the terminator. + /// Throws when no terminator remains, so callers + /// parsing structured blobs can degrade to a hex dump on malformed input. + /// + 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; + } } }