From 93a1653cc7e812efdc3fd5c758163450b13e89e8 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 4 Jul 2026 23:34:37 +0200 Subject: [PATCH] Show text-based resources inline with syntax highlighting Textual assembly resources (JSON, XML, Markdown, plain text, ...) rendered as an opaque byte count with only a Save button. Detect text vs binary from the payload (size cap, BOM-aware decoding, strict UTF-8, rejecting control characters) and pick a highlighting extension by resource-name extension, falling back to content sniffing (angle-bracket for XML/HTML, an actual JsonDocument parse for JSON) when the extension is unknown. Text renders as the view's whole content with the matching highlighting; binary keeps the byte-count-plus-Save presentation. Container resources unpack their entries as raw byte arrays. Route those through the same IResourceNodeFactory pipeline as top-level resources so a nested .baml gets the BAML view, an image its viewer, and so on, instead of the generic byte node; the .resources and !AvaloniaResources views also list their entries by name. Register built-in AvaloniaEdit definitions (JSON, Markdown, ...) with the theme manager on lookup so they follow the dark theme like the bundled ones. Assisted-by: Claude:claude-fable-5:Claude Code --- ILSpy.Tests/Resources/ResourceFactoryTests.cs | 59 ++++ .../Resources/TextResourceDetectionTests.cs | 277 ++++++++++++++++++ ILSpy/TextView/HighlightingService.cs | 10 +- .../AvaloniaResourcesFileTreeNode.cs | 10 + ILSpy/TreeNodes/ResourceEntryNode.cs | 38 ++- ILSpy/TreeNodes/ResourceTreeNode.cs | 20 ++ ILSpy/TreeNodes/ResourcesFileTreeNode.cs | 3 + ILSpy/TreeNodes/TextResourceDetector.cs | 203 +++++++++++++ 8 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 ILSpy.Tests/Resources/TextResourceDetectionTests.cs create mode 100644 ILSpy/TreeNodes/TextResourceDetector.cs diff --git a/ILSpy.Tests/Resources/ResourceFactoryTests.cs b/ILSpy.Tests/Resources/ResourceFactoryTests.cs index 08f1c89d0..42b9e69b6 100644 --- a/ILSpy.Tests/Resources/ResourceFactoryTests.cs +++ b/ILSpy.Tests/Resources/ResourceFactoryTests.cs @@ -246,4 +246,63 @@ public class ResourceFactoryTests } return ms.ToArray(); } + + [AvaloniaTest] + [TestCase("Themes/Generic.baml")] + [TestCase("logo.png")] + [TestCase("favicon.ico")] + public void Byte_Array_Entries_Route_Through_The_Factory_Pipeline(string name) + { + // .resources / !AvaloniaResources containers unpack their entries as raw byte arrays. + // Those entries must go through the same factory dispatch as top-level resources, so a + // .baml entry gets the BAML-to-XAML decompiler view, an image its viewer, and so on — + // not the generic byte-count node. + EnsureComposition(); + + var node = ResourceEntryNode.Create(name, new byte[] { 0x00, 0x01 }); + + node.GetType().Should().NotBe(typeof(ResourceEntryNode), + $"entry '{name}' should be routed to a specialised node, not the generic entry node"); + } + + [AvaloniaTest] + public void Resources_File_Decompile_Lists_Stream_Entries() + { + // Binary entries inside a .resources file only show up as tree children; the container's + // text view must also list them by name so the content is discoverable without expanding + // the tree. + EnsureComposition(); + var node = (ResourcesFileTreeNode)ResourceEntryNode.Create( + new ByteArrayResource("app.g.resources", BuildResources( + new (string, object)[] { + ("assets/data.bin", new byte[] { 1, 2, 3 }), + ("greeting", "hello"), + }))); + node.EnsureLazyChildren(); + var output = new AvaloniaEditTextOutput(); + var language = AppComposition.Current.GetExport().CurrentLanguage; + + node.Decompile(language, output, new DecompilationOptions(new DecompilerSettings())); + + output.GetText().Should().Contain("assets/data.bin"); + } + + [AvaloniaTest] + public void AvaloniaResources_Decompile_Lists_Packed_Entries() + { + // The !AvaloniaResources container packs files behind one index; its text view must + // enumerate the packed paths, mirroring the .resources entry listing. + EnsureComposition(); + var node = (AvaloniaResourcesFileTreeNode)ResourceEntryNode.Create( + new ByteArrayResource("!AvaloniaResources", BuildAvaloniaResources( + ("/App.axaml", Encoding.UTF8.GetBytes("")), + ("/assets/logo.png", new byte[] { 0x89, 0x50, 0x4E, 0x47 })))); + var output = new AvaloniaEditTextOutput(); + var language = AppComposition.Current.GetExport().CurrentLanguage; + + node.Decompile(language, output, new DecompilationOptions(new DecompilerSettings())); + + output.GetText().Should().Contain("/App.axaml"); + output.GetText().Should().Contain("/assets/logo.png"); + } } diff --git a/ILSpy.Tests/Resources/TextResourceDetectionTests.cs b/ILSpy.Tests/Resources/TextResourceDetectionTests.cs new file mode 100644 index 000000000..ad0c77060 --- /dev/null +++ b/ILSpy.Tests/Resources/TextResourceDetectionTests.cs @@ -0,0 +1,277 @@ +// 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.IO; +using System.Linq; +using System.Text; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.Metadata; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class TextResourceDetectionTests +{ + static TextResourceContent? Detect(string name, byte[] payload) + => TextResourceDetector.TryDetectText(name, new MemoryStream(payload)); + + static TextResourceContent? Detect(string name, string payload) + => Detect(name, Encoding.UTF8.GetBytes(payload)); + + // ------------------------------------------------------------------ + // Text vs binary + // ------------------------------------------------------------------ + + [Test] + public void Plain_Ascii_Text_Is_Detected_As_Plain_Text() + { + var result = Detect("readme.txt", "hello world\nsecond line\n"); + ((object?)result).Should().NotBeNull(); + result!.Text.Should().Be("hello world\nsecond line\n"); + result.SyntaxExtension.Should().Be("", ".txt has no syntax highlighting"); + } + + [Test] + public void Utf8_Bom_Is_Stripped_From_The_Displayed_Text() + { + var payload = Encoding.UTF8.GetPreamble() + .Concat(Encoding.UTF8.GetBytes("content")).ToArray(); + var result = Detect("readme.txt", payload); + ((object?)result).Should().NotBeNull(); + result!.Text.Should().Be("content"); + } + + [Test] + public void Utf16_LE_Bom_Text_Is_Detected() + { + var payload = Encoding.Unicode.GetPreamble() + .Concat(Encoding.Unicode.GetBytes("wide text")).ToArray(); + var result = Detect("readme.txt", payload); + ((object?)result).Should().NotBeNull(); + result!.Text.Should().Be("wide text"); + } + + [Test] + public void Png_Payload_Is_Binary() + { + // PNG magic followed by arbitrary bytes; the NUL and 0x89 already disqualify it. + var payload = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01 }; + ((object?)Detect("logo.dat", payload)).Should().BeNull(); + } + + [Test] + public void Nul_Byte_Means_Binary() + { + ((object?)Detect("data", "abc\0def")).Should().BeNull(); + } + + [Test] + public void Invalid_Utf8_Is_Binary() + { + // 0xC3 starts a two-byte sequence; 0x28 is not a valid continuation byte. + ((object?)Detect("data", new byte[] { 0x61, 0xC3, 0x28, 0x62 })).Should().BeNull(); + } + + [Test] + public void Empty_Payload_Is_Not_Text() + { + ((object?)Detect("empty.txt", Array.Empty())).Should().BeNull(); + } + + [Test] + public void Oversized_Payload_Is_Rejected_Without_Reading() + { + using var stream = new HugeUnreadableStream(); + ((object?)TextResourceDetector.TryDetectText("huge.txt", stream)).Should().BeNull(); + } + + /// + /// Claims to be larger than the display cap and throws when anyone tries to read it, + /// proving the size check happens before any I/O. + /// + sealed class HugeUnreadableStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length => TextResourceDetector.MaxDisplayableLength + 1; + public override long Position { get; set; } + public override int Read(byte[] buffer, int offset, int count) + => throw new InvalidOperationException("oversized payload must not be read"); + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => Position; + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + // ------------------------------------------------------------------ + // Format detection by resource-name extension + // ------------------------------------------------------------------ + + [TestCase("data.json", ".json")] + [TestCase("README.md", ".md")] + [TestCase("script.js", ".js")] + [TestCase("styles.css", ".css")] + [TestCase("page.html", ".html")] + [TestCase("query.sql", ".sql")] + [TestCase("setup.ps1", ".ps1")] + [TestCase("source.py", ".py")] + [TestCase("Template.cs", ".cs")] + public void Known_Extensions_Select_Their_Highlighting(string name, string expectedExtension) + { + var result = Detect(name, "content that is not sniffable"); + ((object?)result).Should().NotBeNull(); + result!.SyntaxExtension.Should().Be(expectedExtension); + } + + [TestCase("app.xml")] + [TestCase("strings.resx")] + [TestCase("app.config")] + [TestCase("icon.svg")] + [TestCase("schema.xsd")] + [TestCase("View.axaml")] + public void Xml_Family_Extensions_Select_Xml_Highlighting(string name) + { + var result = Detect(name, "plain payload"); + ((object?)result).Should().NotBeNull(); + result!.SyntaxExtension.Should().Be(".xml"); + } + + // ------------------------------------------------------------------ + // Format detection by content sniffing (unknown / missing extension) + // ------------------------------------------------------------------ + + [Test] + public void Json_Content_Is_Sniffed_Without_Extension() + { + var result = Detect("payload", "{ \"key\": [1, 2, 3], \"nested\": { \"a\": true } }"); + ((object?)result).Should().NotBeNull(); + result!.SyntaxExtension.Should().Be(".json"); + } + + [Test] + public void Xml_Content_Is_Sniffed_Without_Extension() + { + var result = Detect("payload", "\n"); + ((object?)result).Should().NotBeNull(); + result!.SyntaxExtension.Should().Be(".xml"); + } + + [Test] + public void Html_Content_Is_Sniffed_Without_Extension() + { + var result = Detect("payload", "\nhi"); + ((object?)result).Should().NotBeNull(); + result!.SyntaxExtension.Should().Be(".html"); + } + + [Test] + public void Invalid_Json_Sniff_Falls_Back_To_Plain_Text() + { + var result = Detect("payload", "{ this is not valid json"); + ((object?)result).Should().NotBeNull(); + result!.SyntaxExtension.Should().Be(""); + } + + // ------------------------------------------------------------------ + // Display integration: the generic resource nodes render detected text + // with the matching highlighting override + // ------------------------------------------------------------------ + + static void EnsureComposition() => AppComposition.Current.GetExport(); + + static (AvaloniaEditTextOutput Output, Language Language) PrepareDecompile() + { + EnsureComposition(); + var output = new AvaloniaEditTextOutput(); + var language = AppComposition.Current.GetExport().CurrentLanguage; + return (output, language); + } + + [AvaloniaTest] + public void Resources_Entry_Renders_Json_As_Bare_Highlighted_Content() + { + // Entries inside a .resources blob always use the generic ResourceEntryNode; a JSON + // payload must surface as highlighted text only — no byte-count header, no Save button — + // matching how the XML/XAML resource nodes present their content. + var (output, language) = PrepareDecompile(); + const string json = "{ \"answer\": 42 }"; + var node = ResourceEntryNode.Create("settings.json", Encoding.UTF8.GetBytes(json)); + + node.Decompile(language, output, new DecompilationOptions(new DecompilerSettings())); + + output.GetText().Should().Be(json); + output.SyntaxExtensionOverride.Should().Be(".json"); + output.UIElements.Should().BeEmpty("text resources render bare content without the Save button"); + } + + [AvaloniaTest] + public void Assembly_Resource_Renders_Sniffed_Text_As_Bare_Content() + { + // An embedded assembly resource with an unknown extension but textual content must + // render only the text (plain, no highlighting) — header and Save button are for + // binary payloads that have nothing else to show. + var (output, language) = PrepareDecompile(); + const string text = "some plain notes\nwith a second line"; + var node = new ResourceTreeNode(new ByteArrayResource("notes.customext", Encoding.UTF8.GetBytes(text))); + + node.Decompile(language, output, new DecompilationOptions(new DecompilerSettings())); + + output.GetText().Should().Be(text); + output.SyntaxExtensionOverride.Should().Be(""); + output.UIElements.Should().BeEmpty(); + } + + [AvaloniaTest] + public void Binary_Assembly_Resource_Keeps_The_Byte_Dump_Only() + { + var (output, language) = PrepareDecompile(); + var payload = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0xFE }; + var node = new ResourceTreeNode(new ByteArrayResource("logo.dat", payload)); + + node.Decompile(language, output, new DecompilationOptions(new DecompilerSettings())); + + output.SyntaxExtensionOverride.Should().BeNull(); + output.GetText().Should().Contain("7 bytes", "binary resources keep the metadata header"); + output.UIElements.Should().HaveCount(1, "binary resources keep the Save button"); + } + + [AvaloniaTest] + public void Json_Highlighting_Definition_Is_Resolvable() + { + // AvaloniaEdit ships Json.xshd as a built-in; the resource text view depends on the + // by-extension lookup finding it (and .md / .js for the other mapped formats). + HighlightingService.GetByExtension(".json").Should().NotBeNull(); + HighlightingService.GetByExtension(".md").Should().NotBeNull(); + HighlightingService.GetByExtension(".js").Should().NotBeNull(); + } +} diff --git a/ILSpy/TextView/HighlightingService.cs b/ILSpy/TextView/HighlightingService.cs index a10878c7a..845103f37 100644 --- a/ILSpy/TextView/HighlightingService.cs +++ b/ILSpy/TextView/HighlightingService.cs @@ -61,7 +61,15 @@ namespace ICSharpCode.ILSpy.TextView public static IHighlightingDefinition? GetByExtension(string fileExtension) { EnsureRegistered(); - return HighlightingManager.Instance.GetDefinitionByExtension(fileExtension); + var definition = HighlightingManager.Instance.GetDefinitionByExtension(fileExtension); + if (definition != null) + { + // The lookup can also resolve AvaloniaEdit's built-in definitions (JSON, Markdown, + // JavaScript, ...) that resource text rendering relies on; those never pass through + // Load(), so theme them here. Registering is idempotent. + ThemeManager.Current.RegisterThemableDefinition(definition); + } + return definition; } static void Register(string name, string[] extensions, string resourceName) diff --git a/ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs b/ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs index b7b3adc26..277a14dae 100644 --- a/ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs +++ b/ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs @@ -21,10 +21,13 @@ using System.Composition; using System.IO; using System.Linq; +using ICSharpCode.Decompiler; using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Util; using ICSharpCode.ILSpyX.Abstractions; +using ICSharpCode.ILSpy.Languages; + namespace ICSharpCode.ILSpy.TreeNodes { [Export(typeof(IResourceNodeFactory))] @@ -70,5 +73,12 @@ namespace ICSharpCode.ILSpy.TreeNodes catch (BadImageFormatException) { /* malformed — ignore */ } catch (EndOfStreamException) { /* truncated — ignore */ } } + + public override void Decompile(Language language, ITextOutput output, DecompilationOptions options) + { + EnsureLazyChildren(); + base.Decompile(language, output, options); + WriteEntryList(output); + } } } diff --git a/ILSpy/TreeNodes/ResourceEntryNode.cs b/ILSpy/TreeNodes/ResourceEntryNode.cs index c0fe29e14..165a2e3f3 100644 --- a/ILSpy/TreeNodes/ResourceEntryNode.cs +++ b/ILSpy/TreeNodes/ResourceEntryNode.cs @@ -56,6 +56,8 @@ namespace ICSharpCode.ILSpy.TreeNodes public override void Decompile(Language language, ITextOutput output, DecompilationOptions options) { using var data = OpenStream(); + if (WriteTextContent(key, data, output)) + return; language.WriteCommentLine(output, $"{key} = {data.Length} bytes"); if (output is ISmartTextOutput smart) { @@ -65,6 +67,27 @@ namespace ICSharpCode.ILSpy.TreeNodes } } + /// + /// Renders a textual payload as the view's whole content and switches the text view's + /// highlighting to the detected format (XML, JSON, ...), mirroring how the XML/XAML + /// resource nodes present theirs. Returns false for binary payloads, which keep the + /// byte-count-plus-Save presentation instead. + /// + internal static bool WriteTextContent(string resourceName, Stream data, ITextOutput output) + { + var content = TextResourceDetector.TryDetectText(resourceName, data); + if (content == null) + return false; + output.Write(content.Text); + if (output is AvaloniaEditTextOutput aeto) + { + // An empty extension is a deliberate override to plain text: without it the tab + // would fall back to the active language's highlighting (e.g. C#). + aeto.SyntaxExtensionOverride = content.SyntaxExtension; + } + return true; + } + public override bool Save() { SaveAsync().HandleExceptions(); @@ -82,8 +105,21 @@ namespace ICSharpCode.ILSpy.TreeNodes src.CopyTo(dst); } + /// + /// Creates the node for an entry unpacked from a container resource (.resources / + /// !AvaloniaResources). Dispatches through the factory pipeline so typed entries + /// (.baml, images, XML, ...) get their specialised nodes; unclaimed entries fall back + /// to this generic entry node. + /// public static ILSpyTreeNode Create(string name, byte[] data) - => new ResourceEntryNode(name, () => new MemoryStream(data)); + { + var resource = new ByteArrayResource(name, data); + return ResourceNodeFactories + .Select(f => f.CreateNode(resource)) + .OfType() + .FirstOrDefault() + ?? new ResourceEntryNode(name, () => new MemoryStream(data)); + } /// /// Walks and returns the first node any diff --git a/ILSpy/TreeNodes/ResourceTreeNode.cs b/ILSpy/TreeNodes/ResourceTreeNode.cs index 734ee901a..f36c7501e 100644 --- a/ILSpy/TreeNodes/ResourceTreeNode.cs +++ b/ILSpy/TreeNodes/ResourceTreeNode.cs @@ -18,6 +18,7 @@ using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using ICSharpCode.Decompiler; @@ -51,6 +52,11 @@ namespace ICSharpCode.ILSpy.TreeNodes public override void Decompile(Language language, ITextOutput output, DecompilationOptions options) { + using (var data = Resource.TryOpenStream()) + { + if (data != null && ResourceEntryNode.WriteTextContent(Resource.Name, data, output)) + return; + } var sizeInBytes = Resource.TryGetLength(); var sizeInBytesText = sizeInBytes == null ? "" : ", " + sizeInBytes + " bytes"; language.WriteCommentLine(output, $"{Resource.Name} ({Resource.ResourceType}, {Resource.Attributes}{sizeInBytesText})"); @@ -64,6 +70,20 @@ namespace ICSharpCode.ILSpy.TreeNodes } } + /// + /// Writes one line per child node so container resources (.resources, !AvaloniaResources) + /// show their inventory in the text view without the tree having to be expanded. Call + /// after . + /// + private protected void WriteEntryList(ITextOutput output) + { + if (Children.Count == 0) + return; + output.WriteLine(); + foreach (var child in Children.OfType()) + output.WriteLine(child.Text?.ToString() ?? string.Empty); + } + public override bool Save() { SaveAsync().HandleExceptions(); diff --git a/ILSpy/TreeNodes/ResourcesFileTreeNode.cs b/ILSpy/TreeNodes/ResourcesFileTreeNode.cs index 5f8621000..d5ea68b93 100644 --- a/ILSpy/TreeNodes/ResourcesFileTreeNode.cs +++ b/ILSpy/TreeNodes/ResourcesFileTreeNode.cs @@ -201,6 +201,9 @@ namespace ICSharpCode.ILSpy.TreeNodes smart.AddUIElement(() => new ResourceObjectTable(otherEntries)); output.WriteLine(); } + // Binary/stream entries are child nodes rather than table rows; list them by name so + // the container view shows the complete inventory. + WriteEntryList(output); } } } diff --git a/ILSpy/TreeNodes/TextResourceDetector.cs b/ILSpy/TreeNodes/TextResourceDetector.cs new file mode 100644 index 000000000..122b20865 --- /dev/null +++ b/ILSpy/TreeNodes/TextResourceDetector.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.Generic; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace ICSharpCode.ILSpy.TreeNodes +{ + /// + /// Sniffs resource payloads for displayable text: decides text vs binary and picks the + /// file extension used to look up syntax highlighting (XML, JSON, ...) for the text view. + /// + public static class TextResourceDetector + { + /// + /// Payloads larger than this are never rendered as text (the text view would choke on + /// them anyway); they keep the byte-count-plus-Save presentation. + /// + public const long MaxDisplayableLength = 10 * 1024 * 1024; + + /// + /// Maps a resource name's extension to the extension used for the highlighting lookup. + /// Values must be extensions some definition is actually registered under: ".xml" / + /// ".cs" are ILSpy's own XSHDs (see HighlightingService), the rest are AvaloniaEdit + /// built-ins. Empty string = display as plain text. + /// + static readonly Dictionary highlightExtensionByFileExtension = new(StringComparer.OrdinalIgnoreCase) { + // XML family + [".xml"] = ".xml", + [".xsd"] = ".xml", + [".xsl"] = ".xml", + [".xslt"] = ".xml", + [".xaml"] = ".xml", + [".axaml"] = ".xml", + [".config"] = ".xml", + [".manifest"] = ".xml", + [".resx"] = ".xml", + [".nuspec"] = ".xml", + [".targets"] = ".xml", + [".props"] = ".xml", + [".proj"] = ".xml", + [".csproj"] = ".xml", + [".vbproj"] = ".xml", + [".svg"] = ".xml", + [".settings"] = ".xml", + [".wsdl"] = ".xml", + [".xshd"] = ".xml", + // One representative extension per AvaloniaEdit built-in definition + [".json"] = ".json", + [".cs"] = ".cs", + [".js"] = ".js", + [".css"] = ".css", + [".htm"] = ".html", + [".html"] = ".html", + [".md"] = ".md", + [".py"] = ".py", + [".pyw"] = ".py", + [".sql"] = ".sql", + [".ps1"] = ".ps1", + [".psm1"] = ".ps1", + [".psd1"] = ".ps1", + [".vb"] = ".vb", + [".c"] = ".cpp", + [".h"] = ".cpp", + [".cc"] = ".cpp", + [".cpp"] = ".cpp", + [".hpp"] = ".cpp", + [".java"] = ".java", + [".php"] = ".php", + [".patch"] = ".patch", + [".diff"] = ".patch", + // Known text formats without a highlighting definition + [".txt"] = "", + [".ini"] = "", + [".cfg"] = "", + [".conf"] = "", + [".log"] = "", + [".csv"] = "", + [".tsv"] = "", + [".yml"] = "", + [".yaml"] = "", + }; + + /// + /// Returns the payload decoded as text plus the matching highlighting extension, or + /// null when the payload is binary, empty, or too large to render. The stream must be + /// seekable; its position is reset before reading. + /// + public static TextResourceContent? TryDetectText(string resourceName, Stream stream) + { + ArgumentNullException.ThrowIfNull(resourceName); + ArgumentNullException.ThrowIfNull(stream); + + if (!stream.CanSeek || stream.Length == 0 || stream.Length > MaxDisplayableLength) + return null; + stream.Position = 0; + + string? text = TryDecode(stream); + if (text == null || text.Length == 0) + return null; + + string extension = Path.GetExtension(resourceName); + if (highlightExtensionByFileExtension.TryGetValue(extension, out var syntaxExtension)) + return new TextResourceContent(text, syntaxExtension); + return new TextResourceContent(text, SniffSyntax(text)); + } + + /// + /// Decodes the stream as text, or returns null when the payload is not text. A BOM + /// selects UTF-8/16/32; without one, only strict UTF-8 is attempted (which also accepts + /// plain ASCII). Any remaining control character outside \t \n \r \f (including NUL and + /// the U+FFFD replacement char the UTF-16/32 decoders substitute for invalid units) + /// classifies the payload as binary. + /// + static string? TryDecode(Stream stream) + { + var strictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + string text; + try + { + using var reader = new StreamReader(stream, strictUtf8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + text = reader.ReadToEnd(); + } + catch (DecoderFallbackException) + { + return null; + } + + foreach (char c in text) + { + if (c == '\uFFFD') + return null; + if (c < ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\f') + return null; + if (c == '\u007F') + return null; + } + return text; + } + + /// + /// Content-based format detection for names without a recognized extension: XML/HTML by + /// the leading angle bracket, JSON by actually parsing. Anything else is plain text. + /// + static string SniffSyntax(string text) + { + var trimmed = text.AsSpan().TrimStart(); + if (trimmed.IsEmpty) + return ""; + + if (trimmed[0] == '<') + { + if (trimmed.StartsWith(" + /// A resource payload decoded as text, plus the file extension driving the syntax + /// highlighting lookup (empty string = plain text, no highlighting). + /// + public sealed record TextResourceContent(string Text, string SyntaxExtension); +}