Browse Source

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
pull/3852/head
Siegfried Pammer 3 days ago committed by Siegfried Pammer
parent
commit
93a1653cc7
  1. 59
      ILSpy.Tests/Resources/ResourceFactoryTests.cs
  2. 277
      ILSpy.Tests/Resources/TextResourceDetectionTests.cs
  3. 10
      ILSpy/TextView/HighlightingService.cs
  4. 10
      ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs
  5. 38
      ILSpy/TreeNodes/ResourceEntryNode.cs
  6. 20
      ILSpy/TreeNodes/ResourceTreeNode.cs
  7. 3
      ILSpy/TreeNodes/ResourcesFileTreeNode.cs
  8. 203
      ILSpy/TreeNodes/TextResourceDetector.cs

59
ILSpy.Tests/Resources/ResourceFactoryTests.cs

@ -246,4 +246,63 @@ public class ResourceFactoryTests @@ -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<LanguageService>().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("<Application/>")),
("/assets/logo.png", new byte[] { 0x89, 0x50, 0x4E, 0x47 }))));
var output = new AvaloniaEditTextOutput();
var language = AppComposition.Current.GetExport<LanguageService>().CurrentLanguage;
node.Decompile(language, output, new DecompilationOptions(new DecompilerSettings()));
output.GetText().Should().Contain("/App.axaml");
output.GetText().Should().Contain("/assets/logo.png");
}
}

277
ILSpy.Tests/Resources/TextResourceDetectionTests.cs

@ -0,0 +1,277 @@ @@ -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<byte>())).Should().BeNull();
}
[Test]
public void Oversized_Payload_Is_Rejected_Without_Reading()
{
using var stream = new HugeUnreadableStream();
((object?)TextResourceDetector.TryDetectText("huge.txt", stream)).Should().BeNull();
}
/// <summary>
/// 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.
/// </summary>
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", "<?xml version=\"1.0\"?>\n<root><child /></root>");
((object?)result).Should().NotBeNull();
result!.SyntaxExtension.Should().Be(".xml");
}
[Test]
public void Html_Content_Is_Sniffed_Without_Extension()
{
var result = Detect("payload", "<!DOCTYPE html>\n<html><body>hi</body></html>");
((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<MainWindow>();
static (AvaloniaEditTextOutput Output, Language Language) PrepareDecompile()
{
EnsureComposition();
var output = new AvaloniaEditTextOutput();
var language = AppComposition.Current.GetExport<LanguageService>().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();
}
}

10
ILSpy/TextView/HighlightingService.cs

@ -61,7 +61,15 @@ namespace ICSharpCode.ILSpy.TextView @@ -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)

10
ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs

@ -21,10 +21,13 @@ using System.Composition; @@ -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 @@ -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);
}
}
}

38
ILSpy/TreeNodes/ResourceEntryNode.cs

@ -56,6 +56,8 @@ namespace ICSharpCode.ILSpy.TreeNodes @@ -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 @@ -65,6 +67,27 @@ namespace ICSharpCode.ILSpy.TreeNodes
}
}
/// <summary>
/// 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.
/// </summary>
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 @@ -82,8 +105,21 @@ namespace ICSharpCode.ILSpy.TreeNodes
src.CopyTo(dst);
}
/// <summary>
/// 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.
/// </summary>
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<ILSpyTreeNode>()
.FirstOrDefault()
?? new ResourceEntryNode(name, () => new MemoryStream(data));
}
/// <summary>
/// Walks <see cref="ILSpyTreeNode.ResourceNodeFactories"/> and returns the first node any

20
ILSpy/TreeNodes/ResourceTreeNode.cs

@ -18,6 +18,7 @@ @@ -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 @@ -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 @@ -64,6 +70,20 @@ namespace ICSharpCode.ILSpy.TreeNodes
}
}
/// <summary>
/// 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 <see cref="SharpTreeNode.EnsureLazyChildren"/>.
/// </summary>
private protected void WriteEntryList(ITextOutput output)
{
if (Children.Count == 0)
return;
output.WriteLine();
foreach (var child in Children.OfType<ILSpyTreeNode>())
output.WriteLine(child.Text?.ToString() ?? string.Empty);
}
public override bool Save()
{
SaveAsync().HandleExceptions();

3
ILSpy/TreeNodes/ResourcesFileTreeNode.cs

@ -201,6 +201,9 @@ namespace ICSharpCode.ILSpy.TreeNodes @@ -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);
}
}
}

203
ILSpy/TreeNodes/TextResourceDetector.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.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
namespace ICSharpCode.ILSpy.TreeNodes
{
/// <summary>
/// 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.
/// </summary>
public static class TextResourceDetector
{
/// <summary>
/// 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.
/// </summary>
public const long MaxDisplayableLength = 10 * 1024 * 1024;
/// <summary>
/// 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.
/// </summary>
static readonly Dictionary<string, string> 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"] = "",
};
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
static string SniffSyntax(string text)
{
var trimmed = text.AsSpan().TrimStart();
if (trimmed.IsEmpty)
return "";
if (trimmed[0] == '<')
{
if (trimmed.StartsWith("<!DOCTYPE html", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("<html", StringComparison.OrdinalIgnoreCase))
{
return ".html";
}
return ".xml";
}
if (trimmed[0] == '{' || trimmed[0] == '[')
{
try
{
using var _ = JsonDocument.Parse(text, new JsonDocumentOptions {
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
});
return ".json";
}
catch (JsonException)
{
}
}
return "";
}
}
/// <summary>
/// A resource payload decoded as text, plus the file extension driving the syntax
/// highlighting lookup (empty string = plain text, no highlighting).
/// </summary>
public sealed record TextResourceContent(string Text, string SyntaxExtension);
}
Loading…
Cancel
Save