mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3852/head
8 changed files with 618 additions and 2 deletions
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue