diff --git a/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs b/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs index 22bad80fd..56d28692e 100644 --- a/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs @@ -37,6 +37,7 @@ using ICSharpCode.Decompiler.Metadata; using ICSharpCode.ILSpy.Metadata; using ICSharpCode.ILSpy.Metadata.DebugTables; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; using NUnit.Framework; @@ -58,23 +59,28 @@ public class CustomDebugInformationRowDetailsTests { // Synthesize a standalone portable PDB whose CustomDebugInformation table holds one // row per row-details scenario: a text kind (Source Link), an unrecognized kind - // GUID, embedded source in both formats (raw and DEFLATE), and the four structured - // kinds that parse into typed rows. Parents use ascending MethodDef tokens so the - // (parent-sorted) table preserves this row order. + // GUID, the four structured kinds that parse into typed rows, and embedded source in + // both formats (raw and DEFLATE), each parented to a document whose name carries the + // source-language extension. MetadataBuilder sorts the table by its + // HasCustomDebugInformation coded index on serialize, interleaving method- and + // document-parented rows, so tests locate rows by kind GUID rather than by position. 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( + var csharpDocument = AddDocument("/src/Example.cs", KnownGuids.CSharpLanguageGuid); + var vbDocument = AddDocument("/src/Module1.vb", KnownGuids.VBLanguageGuid); + + AddRow(MethodRow(1), KnownGuids.SourceLink, Encoding.UTF8.GetBytes(SourceLinkJson)); + AddRow(MethodRow(2), UnknownKindGuid, new byte[] { 0x01, 0x02, 0x03 }); + AddRow(MethodRow(3), KnownGuids.StateMachineHoistedLocalScopes, HoistedScopesBlob((0, 10), (16, 32))); + AddRow(MethodRow(4), KnownGuids.CompilationOptions, NullTerminatedStrings("language", "C#", "version", "2")); + AddRow(MethodRow(5), KnownGuids.CompilationMetadataReferences, MetadataReferenceBlob( "System.Runtime.dll", "global", flags: 1, timestamp: 0x12345678, fileSize: 1024, ReferenceMvid)); - AddRow(7, KnownGuids.TupleElementNames, NullTerminatedStrings("Item1", "Name")); - AddRow(8, KnownGuids.EmbeddedSource, EmbeddedSourceDeflateBlob); + AddRow(MethodRow(6), KnownGuids.TupleElementNames, NullTerminatedStrings("Item1", "Name")); + AddRow(csharpDocument, KnownGuids.EmbeddedSource, new byte[] { 0x00, 0x00, 0x00, 0x00, 0x41 }); + AddRow(vbDocument, KnownGuids.EmbeddedSource, EmbeddedSourceDeflateBlob); var rowCounts = new int[MetadataTokens.TableCount]; - rowCounts[(int)TableIndex.MethodDef] = 8; + rowCounts[(int)TableIndex.MethodDef] = 6; var pdbBuilder = new PortablePdbBuilder(builder, rowCounts.ToImmutableArray(), entryPoint: default); var blob = new BlobBuilder(); pdbBuilder.Serialize(blob); @@ -82,13 +88,25 @@ public class CustomDebugInformationRowDetailsTests var provider = MetadataReaderProvider.FromPortablePdbImage(blob.ToImmutableArray()); return new MetadataFile(MetadataFile.MetadataFileKind.ProgramDebugDatabase, "synthetic.pdb", provider); - void AddRow(int methodRow, Guid kind, byte[] value) + static EntityHandle MethodRow(int methodRow) + => MetadataTokens.EntityHandle(TableIndex.MethodDef, methodRow); + + void AddRow(EntityHandle parent, Guid kind, byte[] value) { builder.AddCustomDebugInformation( - MetadataTokens.EntityHandle(TableIndex.MethodDef, methodRow), + parent, builder.GetOrAddGuid(kind), builder.GetOrAddBlob(value)); } + + DocumentHandle AddDocument(string name, Guid language) + { + return builder.AddDocument( + builder.GetOrAddDocumentName(name), + builder.GetOrAddGuid(KnownGuids.HashAlgorithmSHA256), + builder.GetOrAddBlob(new byte[32]), + builder.GetOrAddGuid(language)); + } } static byte[] BuildEmbeddedSourceDeflateBlob(string text) @@ -156,6 +174,13 @@ public class CustomDebugInformationRowDetailsTests .Select(h => new CustomDebugInformationEntry(metadataFile, h)) .ToList(); + static CustomDebugInformationEntry ByKind(IEnumerable entries, Guid kind) + => entries.Single(e => e.KindGUID == kind); + + /// The embedded-source rows in table order: raw (".cs" document) first, DEFLATE (".vb" document) second. + static List EmbeddedSourceRows(IEnumerable entries) + => entries.Where(e => e.KindGUID == KnownGuids.EmbeddedSource).ToList(); + [Test] public void Offset_And_Split_Kind_Columns_Match_The_Tables_Conventions() { @@ -166,12 +191,10 @@ public class CustomDebugInformationRowDetailsTests var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[0].Kind.Should().BePositive("the Kind column is the 1-based GUID heap offset"); - entries[0].KindGUID.Should().Be(KnownGuids.SourceLink); - entries[0].KindString.Should().Be("Source Link (C# / VB)"); - entries[1].KindGUID.Should().Be(UnknownKindGuid); - entries[1].KindString.Should().Be("Unknown"); - entries[3].KindString.Should().Be("State Machine Hoisted Local Scopes (C# / VB)"); + ByKind(entries, KnownGuids.SourceLink).Kind.Should().BePositive("the Kind column is the 1-based GUID heap offset"); + ByKind(entries, KnownGuids.SourceLink).KindString.Should().Be("Source Link (C# / VB)"); + ByKind(entries, UnknownKindGuid).KindString.Should().Be("Unknown"); + ByKind(entries, KnownGuids.StateMachineHoistedLocalScopes).KindString.Should().Be("State Machine Hoisted Local Scopes (C# / VB)"); int rowSize = metadataFile.Metadata.GetTableRowSize(TableIndex.CustomDebugInformation); entries[0].Offset.Should().BePositive("the table lives at a real offset inside the PDB metadata"); @@ -186,21 +209,25 @@ public class CustomDebugInformationRowDetailsTests var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[3].RowDetails.Should().BeAssignableTo>() + ByKind(entries, KnownGuids.StateMachineHoistedLocalScopes).RowDetails + .Should().BeAssignableTo>() .Which.Should().Equal( new HoistedLocalScopeDetail(0, 10), new HoistedLocalScopeDetail(16, 32)); - entries[4].RowDetails.Should().BeAssignableTo>() + ByKind(entries, KnownGuids.CompilationOptions).RowDetails + .Should().BeAssignableTo>() .Which.Should().Equal( new CompilationOptionDetail("language", "C#"), new CompilationOptionDetail("version", "2")); - entries[5].RowDetails.Should().BeAssignableTo>() + ByKind(entries, KnownGuids.CompilationMetadataReferences).RowDetails + .Should().BeAssignableTo>() .Which.Should().Equal( new MetadataReferenceDetail("System.Runtime.dll", "global", 1, 0x12345678, 1024, ReferenceMvid)); - entries[6].RowDetails.Should().BeAssignableTo>() + ByKind(entries, KnownGuids.TupleElementNames).RowDetails + .Should().BeAssignableTo>() .Which.Should().Equal( new TupleElementNameDetail("Item1"), new TupleElementNameDetail("Name")); @@ -212,18 +239,25 @@ public class CustomDebugInformationRowDetailsTests 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"); + ByKind(entries, KnownGuids.SourceLink).RowDetails.Should().Be(new TextBlobDetail(SourceLinkJson, ".json"), + "source link blobs are UTF-8 JSON"); + ByKind(entries, UnknownKindGuid).RowDetails.Should().Be(new TextBlobDetail("01-02-03"), + "unrecognized kinds degrade to a plain hex dump"); } [Test] public void RowDetails_Decodes_Embedded_Source_To_The_Document_Text() { + // The parent document's file extension travels with the decoded text so the details + // area can highlight the source in its own language. var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[2].RowDetails.Should().Be("A", "a zero format header means the document bytes follow uncompressed"); - entries[7].RowDetails.Should().Be(EmbeddedSourceText, "a positive format header means the document is DEFLATE-compressed"); + var embedded = EmbeddedSourceRows(entries); + embedded[0].RowDetails.Should().Be(new TextBlobDetail("A", ".cs"), + "a zero format header means the document bytes follow uncompressed"); + embedded[1].RowDetails.Should().Be(new TextBlobDetail(EmbeddedSourceText, ".vb"), + "a positive format header means the document is DEFLATE-compressed"); } [Test] @@ -232,9 +266,11 @@ public class CustomDebugInformationRowDetailsTests var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[0].Info.Should().BeNull("only embedded source carries a format header to summarize"); - entries[2].Info.Should().Be("Raw, 5 bytes"); - entries[7].Info.Should().Be( + ByKind(entries, KnownGuids.SourceLink).Info.Should().BeNull( + "only embedded source carries a format header to summarize"); + var embedded = EmbeddedSourceRows(entries); + embedded[0].Info.Should().Be("Raw, 5 bytes"); + embedded[1].Info.Should().Be( $"DEFLATE, {EmbeddedSourceDeflateBlob.Length} bytes, {Encoding.UTF8.GetByteCount(EmbeddedSourceText)} uncompressed"); } @@ -242,8 +278,8 @@ public class CustomDebugInformationRowDetailsTests 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. + // syntax-highlighting editor, 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(); @@ -252,13 +288,16 @@ public class CustomDebugInformationRowDetailsTests tab.RowDetailsTemplate.Should().NotBeNull(); var entries = tab.Items.Cast().ToList(); - var shell = tab.RowDetailsTemplate!.Build(entries[0]) + var sourceLink = ByKind(entries, KnownGuids.SourceLink); + var shell = tab.RowDetailsTemplate!.Build(sourceLink) .Should().BeOfType().Subject; - shell.DataContext = entries[0]; - shell.Content.Should().BeOfType().Which.Text.Should().Be(SourceLinkJson); + shell.DataContext = sourceLink; + var editor = shell.Content.Should().BeOfType().Subject; + editor.Text.Should().Be(SourceLinkJson); + editor.SyntaxHighlighting.Should().NotBeNull("source-link JSON gets JSON highlighting"); - shell.DataContext = entries[4]; + shell.DataContext = ByKind(entries, KnownGuids.CompilationOptions); var optionsGrid = shell.Content.Should().BeOfType().Subject; ((IEnumerable)optionsGrid.ItemsSource!).Cast().Should().HaveCount(2); } diff --git a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs index 61523803b..6228bf626 100644 --- a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs @@ -29,6 +29,7 @@ using Avalonia.VisualTree; using AwesomeAssertions; using ICSharpCode.ILSpy.Metadata; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; @@ -171,11 +172,37 @@ public class MetadataRowDetailsTests "the last column takes the leftover width"); } + [AvaloniaTest] + public void Text_Blob_Is_A_Read_Only_Editor_With_Extension_Driven_Highlighting() + { + // Text payloads are code (embedded source, source-link JSON): they render in the + // theme-aware AvaloniaEdit editor, which adds syntax colours and virtualizes long + // documents, while staying read-only but selectable. The optional extension picks + // the highlighting; without one (hex dumps) the text stays plain. + var editor = MetadataRowDetails.BuildTextBlob("class C { }", ".cs") + .Should().BeOfType().Subject; + editor.Text.Should().Be("class C { }"); + editor.IsReadOnly.Should().BeTrue(); + editor.WordWrap.Should().BeTrue(); + editor.MaxHeight.Should().Be(400, "the host row must stay bounded; the editor scrolls internally"); + editor.SyntaxHighlighting.Should().NotBeNull(); + editor.SyntaxHighlighting!.Name.Should().Be("C#"); + + var json = (DecompilerTextEditor)MetadataRowDetails.BuildTextBlob("{ }", ".json"); + json.SyntaxHighlighting.Should().NotBeNull("AvaloniaEdit ships a built-in JSON definition"); + + var plain = (DecompilerTextEditor)MetadataRowDetails.BuildTextBlob("01-02-03"); + plain.SyntaxHighlighting.Should().BeNull(); + + var unknown = (DecompilerTextEditor)MetadataRowDetails.BuildTextBlob("text", ".xyz"); + unknown.SyntaxHighlighting.Should().BeNull("an unrecognized extension degrades to plain text"); + } + [AvaloniaTest] public async Task Double_Tap_Inside_The_Details_Area_Does_Not_Resolve_To_An_Activatable_Row() { // Row activation navigates away from the metadata view. A double-click inside the - // details area (e.g. word-selection in an embedded-source TextBox, or a click in the + // details area (e.g. word-selection in an embedded-source editor, or a click in the // flags sub-grid) is interacting with the details content, not requesting navigation, // so the row-resolution walk must reject sources under the details presenter. var (window, vm) = await TestHarness.BootAsync(); diff --git a/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs b/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs index e7cc6cff2..fed0a7d7f 100644 --- a/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs +++ b/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs @@ -76,7 +76,7 @@ namespace ICSharpCode.ILSpy.Metadata.DebugTables static Control? BuildRowDetailsContent(object? item) { return (item as CustomDebugInformationEntry)?.RowDetails switch { - string text => MetadataRowDetails.BuildTextBlob(text), + TextBlobDetail blob => MetadataRowDetails.BuildTextBlob(blob.Text, blob.HighlightExtension), IReadOnlyList rows => MetadataRowDetails.BuildDetailsGrid(rows, ("Start Offset", nameof(HoistedLocalScopeDetail.StartOffset)), ("Length", nameof(HoistedLocalScopeDetail.Length))), @@ -197,9 +197,11 @@ namespace ICSharpCode.ILSpy.Metadata.DebugTables /// /// Parsed view of the Value blob for the row-details area. Structured kinds become - /// typed row lists, source-link blobs the decoded JSON text, embedded source the - /// (decompressed) document text, everything else (including malformed blobs) a hex - /// dump. Cached — the details area re-requests it on every selection change. + /// typed row lists; text payloads become a — source-link + /// blobs the decoded JSON, embedded source the (decompressed) document text tagged + /// with its document's file extension, everything else (including malformed blobs) + /// a plain hex dump. Cached — the details area re-requests it on every selection + /// change. /// public object? RowDetails { get { @@ -215,7 +217,7 @@ namespace ICSharpCode.ILSpy.Metadata.DebugTables } catch (Exception ex) when (ex is BadImageFormatException or InvalidDataException) { - return rowDetails = metadataFile.Metadata.GetBlobReader(debugInfo.Value).ToHexString(); + return rowDetails = new TextBlobDetail(metadataFile.Metadata.GetBlobReader(debugInfo.Value).ToHexString()); } } } @@ -231,13 +233,13 @@ namespace ICSharpCode.ILSpy.Metadata.DebugTables return list; } if (kind == KnownGuids.SourceLink) - return reader.ReadUTF8(reader.RemainingBytes); + return new TextBlobDetail(reader.ReadUTF8(reader.RemainingBytes), ".json"); if (kind == KnownGuids.EmbeddedSource) { var embeddedSourceFormat = reader.ReadInt32(); if (embeddedSourceFormat < 0) // unknown format, show raw data as hex - return reader.ToHexString(); + return new TextBlobDetail(reader.ToHexString()); var embeddedSourceBytes = reader.ReadBytes(reader.RemainingBytes); Stream embeddedSourceByteStream = new MemoryStream(embeddedSourceBytes); @@ -245,8 +247,10 @@ namespace ICSharpCode.ILSpy.Metadata.DebugTables if (embeddedSourceFormat > 0) // positive length means the data is compressed using DEFLATE embeddedSourceByteStream = new System.IO.Compression.DeflateStream(embeddedSourceByteStream, System.IO.Compression.CompressionMode.Decompress); - var textReader = new StreamReader(embeddedSourceByteStream, detectEncodingFromByteOrderMarks: true); - return textReader.ReadToEnd(); + // Disposing the reader disposes the whole wrapped chain (DeflateStream's + // inflater would otherwise wait for its finalizer). + using var textReader = new StreamReader(embeddedSourceByteStream, detectEncodingFromByteOrderMarks: true); + return new TextBlobDetail(textReader.ReadToEnd(), GetEmbeddedSourceExtension()); } if (kind == KnownGuids.CompilationOptions) { @@ -281,7 +285,21 @@ namespace ICSharpCode.ILSpy.Metadata.DebugTables list.Add(new TupleElementNameDetail(reader.ReadUTF8StringNullTerminated())); return list; } - return reader.ToHexString(); + return new TextBlobDetail(reader.ToHexString()); + } + + string? GetEmbeddedSourceExtension() + { + // The parent of an embedded-source row is the Document whose text the blob + // holds; the document name's file extension selects the syntax highlighting + // for the decoded source. + if (debugInfo.Parent.Kind != HandleKind.Document) + return null; + var document = metadataFile.Metadata.GetDocument((DocumentHandle)debugInfo.Parent); + if (document.Name.IsNil) + return null; + string extension = Path.GetExtension(metadataFile.Metadata.GetString(document.Name)); + return string.IsNullOrEmpty(extension) ? null : extension; } public CustomDebugInformationEntry(MetadataFile metadataFile, CustomDebugInformationHandle handle) diff --git a/ILSpy/Metadata/MetadataRowDetails.cs b/ILSpy/Metadata/MetadataRowDetails.cs index a9a42c318..2d50d8e11 100644 --- a/ILSpy/Metadata/MetadataRowDetails.cs +++ b/ILSpy/Metadata/MetadataRowDetails.cs @@ -22,10 +22,12 @@ using System.Collections.Generic; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Media; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; namespace ICSharpCode.ILSpy.Metadata @@ -56,6 +58,13 @@ namespace ICSharpCode.ILSpy.Metadata } } + /// + /// Decoded text payload of a row (embedded source, source-link JSON, hex dump), tagged + /// with the file extension (".cs", ".json") that selects the syntax highlighting for its + /// display, or for plain text. + /// + public sealed record TextBlobDetail(string Text, string? HighlightExtension = null); + /// /// Factories for the row-details area of metadata grids: the DataContext-tracking shell /// template plus the content shapes shared across tables (flag-bit breakdown, decoded @@ -109,16 +118,29 @@ namespace ICSharpCode.ILSpy.Metadata return grid; } - /// Read-only, word-wrapped view of a decoded text blob (source, JSON, hex). - public static Control BuildTextBlob(string text) + /// + /// Read-only, word-wrapped view of a decoded text blob (source, JSON, hex), rendered + /// in the theme-aware AvaloniaEdit editor: text payloads are mostly code, so they get + /// syntax colours (selected by , e.g. ".cs" or + /// ".json"; stays plain) and line virtualization keeps large + /// embedded-source documents cheap. Height is bounded so the host row cannot grow + /// unbounded; the editor scrolls internally. + /// + public static Control BuildTextBlob(string text, string? highlightExtension = null) { ArgumentNullException.ThrowIfNull(text); - return new TextBox { + var editor = new DecompilerTextEditor { Text = text, IsReadOnly = true, - TextWrapping = TextWrapping.Wrap, + WordWrap = true, MaxHeight = 400, + FontFamily = new FontFamily("Consolas, Menlo, Monospace"), + FontSize = 13, }; + if (highlightExtension != null) + editor.SyntaxHighlighting = HighlightingService.GetByExtension(highlightExtension); + editor.Bind(TemplatedControl.BackgroundProperty, editor.GetResourceObservable("ILSpy.EditorBackground")); + return editor; } /// diff --git a/ILSpy/TextView/DecompilerTextEditor.cs b/ILSpy/TextView/DecompilerTextEditor.cs index 8dac10e4c..b0e5a89f8 100644 --- a/ILSpy/TextView/DecompilerTextEditor.cs +++ b/ILSpy/TextView/DecompilerTextEditor.cs @@ -36,11 +36,6 @@ namespace ICSharpCode.ILSpy.TextView /// public class DecompilerTextEditor : TextEditor { - public DecompilerTextEditor() - { - ThemeManager.Current.ThemeChanged += OnThemeChanged; - } - // Avalonia resolves the control template via the runtime type; subclasses of a // templated control inherit the base template only when StyleKeyOverride is // pointed at the base. Without this override AvaloniaEdit's template doesn't @@ -61,6 +56,13 @@ namespace ICSharpCode.ILSpy.TextView TextArea?.TextView?.Redraw(); } + protected override void OnAttachedToVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + ThemeManager.Current.ThemeChanged += OnThemeChanged; + TextArea?.TextView?.Redraw(); + } + protected override void OnDetachedFromVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) { ThemeManager.Current.ThemeChanged -= OnThemeChanged;