Browse Source

BAML→XAML integration for resource viewer + project export

Adds ICSharpCode.BamlDecompiler as a ProjectReference, ports IResourceFileHandler +
ResourceFileHandlerContext from WPF, and plumbs both through:

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
c8661c9ad2
  1. 138
      ILSpy.Tests/Resources/BamlResourceTests.cs
  2. 1
      ILSpy.Tests/Resources/ResourceFactoryTests.cs
  3. 1
      ILSpy/ILSpy.csproj
  4. 65
      ILSpy/Languages/CSharpLanguage.cs
  5. 81
      ILSpy/Languages/IResourceFileHandler.cs
  6. 80
      ILSpy/TreeNodes/BamlResourceEntryNode.cs
  7. 108
      ILSpy/TreeNodes/BamlResourceNodeFactory.cs

138
ILSpy.Tests/Resources/BamlResourceTests.cs

@ -0,0 +1,138 @@ @@ -0,0 +1,138 @@
// 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.Linq;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpyX.Abstractions;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.Baml;
using ILSpy.Languages;
using ILSpy.TreeNodes;
using ILSpy.Views;
using NUnit.Framework;
using IResourceFileHandler = ILSpy.Languages.IResourceFileHandler;
using ResourceFileHandlerContext = ILSpy.Languages.ResourceFileHandlerContext;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class BamlResourceTests
{
// Boot composition once per test so AppComposition.Current is live for the MEF lookups
// the factory/handler depend on. Resolving the MainWindow type forces App.Initialize.
static void EnsureComposition() => AppComposition.Current.GetExport<MainWindow>();
[AvaloniaTest]
public void Factory_Creates_BamlResourceEntryNode_For_Baml_Resource()
{
// The MEF-discovered BamlResourceNodeFactory must claim any `.baml`-named resource
// and wrap it in a BamlResourceEntryNode, regardless of the bytes inside the stream.
// Routing is name-based, mirroring the XML / image / cursor factories — actual BAML
// parsing happens lazily when the user selects the node.
// Arrange — boot composition; build a dummy .baml-named resource (bytes don't matter
// for the routing decision).
EnsureComposition();
var resource = new ByteArrayResource("Pages/MyWindow.baml", new byte[] { 0, 1, 2 });
// Act — dispatch through the central resource factory pipeline.
var node = ResourceEntryNode.Create(resource);
// Assert — comes back as a BamlResourceEntryNode, not the generic fallback.
node.GetType().Should().Be(typeof(BamlResourceEntryNode));
}
[AvaloniaTest]
public void Factory_Ignores_Non_Baml_Resource_Names()
{
// The BAML factory must only claim `.baml` resources. Anything else flows through
// to other factories or the generic ResourceTreeNode fallback. A misfire here would
// hijack arbitrary streams and crash on first selection (XamlDecompiler.Decompile
// throws on non-BAML input).
// Arrange + Act — feed a non-baml name straight to the BAML factory.
var factory = new BamlResourceNodeFactory();
var node = factory.CreateNode(new ByteArrayResource("readme.txt", new byte[] { 0, 1, 2 }));
// Assert — factory yields null so the dispatcher tries the next candidate.
node.Should().BeNull();
}
[AvaloniaTest]
public void File_Handler_Claims_Baml_Extension_Case_Insensitive()
{
// During project export the handler is consulted via CanHandle; first claim wins.
// The match is case-insensitive on the extension (`.BAML` from an obfuscated assembly
// must still produce a XAML output).
// Arrange — boot composition; build a context with a default DecompilationOptions
// (the handler doesn't touch the options for CanHandle).
EnsureComposition();
var handler = new BamlResourceFileHandler();
var context = new ResourceFileHandlerContext(new DecompilationOptions());
// Act + Assert — three positive variants and two rejections.
handler.CanHandle("MainWindow.baml", context).Should().BeTrue();
handler.CanHandle("themes/Generic.BAML", context).Should().BeTrue();
handler.CanHandle("Resources/Strings.baml", context).Should().BeTrue();
handler.CanHandle("readme.txt", context).Should().BeFalse();
handler.CanHandle("Window.xaml", context).Should().BeFalse();
}
[AvaloniaTest]
public void File_Handler_Reports_Page_Entry_Type()
{
// EntryType drives the MSBuild item group the produced file lands in. BAML→XAML
// must go under `<Page>` so MSBuild's XAML compiler picks it up and regenerates the
// matching .g.cs partial on rebuild — same as the WPF side.
// Arrange + Act + Assert — exact string match (case-sensitive; MSBuild element names
// are case-insensitive in practice but the canonical form is "Page").
new BamlResourceFileHandler().EntryType.Should().Be("Page");
}
[AvaloniaTest]
public void Mef_Discovers_Baml_Factory_And_File_Handler()
{
// Both exports must surface through AppComposition. If `[Export]` / `[Shared]` got
// dropped during a future refactor the pane would silently dump raw bytes for .baml
// and project export would emit a bare `EmbeddedResource` instead of a `<Page>` —
// detect that regression here.
// Arrange — boot composition.
EnsureComposition();
// Act — pull every resource-node-factory and every file-handler from the container.
var factories = AppComposition.Current.GetExports<IResourceNodeFactory>().ToList();
var handlers = AppComposition.Current.GetExports<IResourceFileHandler>().ToList();
// Assert — at least one of each is our BAML implementation.
factories.Should().ContainItemsAssignableTo<BamlResourceNodeFactory>();
handlers.Should().ContainItemsAssignableTo<BamlResourceFileHandler>();
}
}

1
ILSpy.Tests/Resources/ResourceFactoryTests.cs

@ -54,6 +54,7 @@ public class ResourceFactoryTests @@ -54,6 +54,7 @@ public class ResourceFactoryTests
[TestCase("config.xml")]
[TestCase("transform.xslt")]
[TestCase("Window.xaml")]
[TestCase("MainWindow.baml")]
[TestCase("logo.png")]
[TestCase("logo.gif")]
[TestCase("logo.bmp")]

1
ILSpy/ILSpy.csproj

@ -74,6 +74,7 @@ @@ -74,6 +74,7 @@
<ItemGroup>
<ProjectReference Include="..\ICSharpCode.ILSpyX\ICSharpCode.ILSpyX.csproj" />
<ProjectReference Include="..\ICSharpCode.Decompiler\ICSharpCode.Decompiler.csproj" />
<ProjectReference Include="..\ICSharpCode.BamlDecompiler\ICSharpCode.BamlDecompiler.csproj" />
</ItemGroup>
<!-- The Debug Steps pane is a DEBUG-only diagnostic. The code-behind and viewmodel

65
ILSpy/Languages/CSharpLanguage.cs

@ -338,7 +338,9 @@ namespace ILSpy.Languages @@ -338,7 +338,9 @@ namespace ILSpy.Languages
var targetDirectory = options.SaveAsProjectDirectory!;
var resolver = assembly.GetAssemblyResolver(loadOnDemand: options.DecompilerSettings.AutoLoadAssemblyReferences);
var debugInfo = assembly.GetDebugInfoOrNull();
var decompiler = new WholeProjectDecompiler(
var decompiler = new ResourceHandlerProjectDecompiler(
assembly,
options,
options.DecompilerSettings,
resolver,
projectWriter: null,
@ -354,6 +356,67 @@ namespace ILSpy.Languages @@ -354,6 +356,67 @@ namespace ILSpy.Languages
return id;
}
/// <summary>
/// <see cref="WholeProjectDecompiler"/> subclass that delegates resource entries to
/// MEF-discovered <see cref="IResourceFileHandler"/> implementations. The first
/// handler whose <c>CanHandle</c> returns true wins; its emitted file plus any
/// partial-type info or extra MSBuild properties land in the produced .csproj.
/// Falls through to <see cref="WholeProjectDecompiler.WriteResourceToFile"/>'s
/// default "raw bytes as embedded resource" behaviour when no handler claims it.
/// </summary>
sealed class ResourceHandlerProjectDecompiler : WholeProjectDecompiler
{
readonly LoadedAssembly assembly;
readonly DecompilationOptions options;
static readonly IReadOnlyList<IResourceFileHandler> handlers = TryDiscoverHandlers();
public ResourceHandlerProjectDecompiler(
LoadedAssembly assembly,
DecompilationOptions options,
DecompilerSettings settings,
IAssemblyResolver resolver,
IProjectFileWriter? projectWriter,
AssemblyReferenceClassifier? assemblyReferenceClassifier,
ICSharpCode.Decompiler.DebugInfo.IDebugInfoProvider? debugInfoProvider)
: base(settings, resolver, projectWriter!, assemblyReferenceClassifier!, debugInfoProvider!)
{
this.assembly = assembly;
this.options = options;
}
protected override IEnumerable<ProjectItemInfo> WriteResourceToFile(string fileName, string resourceName, Stream entryStream)
{
var context = new ResourceFileHandlerContext(options);
foreach (var handler in handlers)
{
if (!handler.CanHandle(fileName, context))
continue;
entryStream.Position = 0;
fileName = handler.WriteResourceToFile(assembly, fileName, entryStream, context);
var item = new ProjectItemInfo(handler.EntryType, fileName) { PartialTypes = context.PartialTypes };
foreach (var (k, v) in context.AdditionalProperties)
item.AdditionalProperties.Add(k, v);
return new[] { item };
}
return base.WriteResourceToFile(fileName, resourceName, entryStream);
}
static IReadOnlyList<IResourceFileHandler> TryDiscoverHandlers()
{
try
{
return AppEnv.AppComposition.Current.GetExports<IResourceFileHandler>().ToArray();
}
catch
{
// Composition isn't available in tests that bypass the host (e.g. invoking
// DecompileAsProject directly with a self-built LoadedAssembly). Fall back
// to the raw-bytes behaviour from the base class.
return System.Array.Empty<IResourceFileHandler>();
}
}
}
static List<EntityHandle> CollectFieldsAndCtors(ITypeDefinition type, bool isStatic)
{
var members = new List<EntityHandle>();

81
ILSpy/Languages/IResourceFileHandler.cs

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
// 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.Collections.Generic;
using System.IO;
using ICSharpCode.Decompiler;
using ICSharpCode.ILSpyX;
namespace ILSpy.Languages
{
/// <summary>
/// Plug-in contract for converting a resource entry from its raw byte form into a
/// language-specific source file during project export. Implementations are MEF-discovered
/// via <c>[Export(typeof(IResourceFileHandler))]</c> and consulted by
/// <see cref="CSharpLanguage"/>'s project-export path; the first one to <see cref="CanHandle"/>
/// the resource wins. The handler returns the on-disk file name it emitted so the project
/// file picks the right MSBuild item group + Generator / SubType properties.
/// </summary>
public interface IResourceFileHandler
{
/// <summary>
/// MSBuild item type for the produced project entry — <c>"Page"</c> for BAML→XAML,
/// <c>"EmbeddedResource"</c> for the default raw fallback, etc.
/// </summary>
string EntryType { get; }
/// <summary>Returns true when this handler can process the named resource.</summary>
bool CanHandle(string name, ResourceFileHandlerContext context);
/// <summary>
/// Writes the decoded resource into the project directory (taken from
/// <see cref="ResourceFileHandlerContext.DecompilationOptions"/>) and returns the
/// resulting file name (relative to that directory). May add partial-type info to
/// <paramref name="context"/> for downstream WPF page x:Class binding.
/// </summary>
string WriteResourceToFile(LoadedAssembly assembly, string fileName, Stream stream, ResourceFileHandlerContext context);
}
/// <summary>
/// Per-project-export context shared across every <see cref="IResourceFileHandler"/>
/// invocation in a single export run. Collects partial-type info and additional MSBuild
/// properties; <see cref="CSharpLanguage"/>'s ILSpyWholeProjectDecompiler stitches them
/// into the produced .csproj.
/// </summary>
public class ResourceFileHandlerContext
{
readonly List<ICSharpCode.Decompiler.PartialTypeInfo> partialTypes = new();
internal List<ICSharpCode.Decompiler.PartialTypeInfo> PartialTypes => partialTypes;
readonly Dictionary<string, string> additionalProperties = new();
public Dictionary<string, string> AdditionalProperties => additionalProperties;
public DecompilationOptions DecompilationOptions { get; }
public ResourceFileHandlerContext(DecompilationOptions options)
{
this.DecompilationOptions = options;
}
public void AddPartialTypeInfo(ICSharpCode.Decompiler.PartialTypeInfo info)
{
this.PartialTypes.Add(info);
}
}
}

80
ILSpy/TreeNodes/BamlResourceEntryNode.cs

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
// 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 ICSharpCode.BamlDecompiler;
using ICSharpCode.Decompiler;
using ILSpy.Languages;
using ILSpy.TextView;
using ILSpy.TreeNodes;
namespace ILSpy.Baml
{
/// <summary>
/// Tree node for a single <c>.baml</c> resource entry inside a WPF/UWP assembly's
/// resource file. Selecting the node decompiles the BAML stream into XAML on a
/// background thread and emits the resulting XML into the editor (XML highlighting
/// kicks in via <c>SyntaxExtensionOverride</c>).
/// </summary>
public sealed class BamlResourceEntryNode : ResourceEntryNode
{
public BamlResourceEntryNode(string key, Func<Stream> openStream)
: base(key, openStream)
{
}
public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
{
try
{
LoadBaml(output, options);
// Surface the rendered XAML as XML in the editor so the highlighter kicks in
// and the foldings strategy collapses nested elements. The smart-output sink
// reads this override before falling back to the language's FileExtension.
if (output is AvaloniaEditTextOutput edit)
edit.SyntaxExtensionOverride = ".xml";
}
catch (Exception ex)
{
language.WriteCommentLine(output, "BAML decompilation failed:");
output.WriteLine(ex.ToString());
}
}
void LoadBaml(ITextOutput output, DecompilationOptions options)
{
var assemblyNode = this.Ancestors().OfType<AssemblyTreeNode>().First();
var assembly = assemblyNode.LoadedAssembly;
using var data = OpenStream();
var typeSystem = new BamlDecompilerTypeSystem(
assembly.GetMetadataFileOrNull()!,
assembly.GetAssemblyResolver());
var decompiler = new XamlDecompiler(typeSystem, new BamlDecompilerSettings {
ThrowOnAssemblyResolveErrors = options.DecompilerSettings.ThrowOnAssemblyResolveErrors,
}) {
CancellationToken = options.CancellationToken,
};
var result = decompiler.Decompile(data);
output.Write(result.Xaml.ToString());
}
}
}

108
ILSpy/TreeNodes/BamlResourceNodeFactory.cs

@ -0,0 +1,108 @@ @@ -0,0 +1,108 @@
// 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.Composition;
using System.IO;
using ICSharpCode.BamlDecompiler;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.Abstractions;
using ILSpy.Languages;
using ILSpy.TreeNodes;
using IResourceFileHandler = ILSpy.Languages.IResourceFileHandler;
using ResourceFileHandlerContext = ILSpy.Languages.ResourceFileHandlerContext;
namespace ILSpy.Baml
{
/// <summary>
/// Tree-node factory that claims <c>.baml</c> resource entries inside an embedded
/// <c>.resources</c> file and wraps them in a <see cref="BamlResourceEntryNode"/>.
/// Discovered by MEF via <see cref="ILSpyTreeNode.ResourceNodeFactories"/>.
/// </summary>
[Export(typeof(IResourceNodeFactory))]
[Shared]
public sealed class BamlResourceNodeFactory : IResourceNodeFactory
{
public ITreeNode? CreateNode(Resource resource)
{
if (resource.Name.EndsWith(".baml", StringComparison.OrdinalIgnoreCase))
return new BamlResourceEntryNode(resource.Name, () => resource.TryOpenStream() ?? Stream.Null);
return null;
}
}
/// <summary>
/// Project-export resource handler that intercepts <c>.baml</c> entries during
/// "Save Code" / project export, decompiles them to XAML, and writes them as
/// MSBuild <c>&lt;Page&gt;</c> items. The accompanying generated code-behind
/// partial-class members are surfaced via <see cref="PartialTypeInfo"/> so the
/// CSharp project decompiler emits matching <c>InitializeComponent</c> stubs.
/// </summary>
[Export(typeof(IResourceFileHandler))]
[Shared]
public sealed class BamlResourceFileHandler : IResourceFileHandler
{
public string EntryType => "Page";
public bool CanHandle(string name, ResourceFileHandlerContext context)
=> name.EndsWith(".baml", StringComparison.OrdinalIgnoreCase);
public string WriteResourceToFile(LoadedAssembly assembly, string fileName, Stream stream, ResourceFileHandlerContext context)
{
var typeSystem = new BamlDecompilerTypeSystem(
assembly.GetMetadataFileOrNull()!,
assembly.GetAssemblyResolver());
var decompiler = new XamlDecompiler(typeSystem, new BamlDecompilerSettings {
ThrowOnAssemblyResolveErrors = context.DecompilationOptions.DecompilerSettings.ThrowOnAssemblyResolveErrors,
}) {
CancellationToken = context.DecompilationOptions.CancellationToken,
};
var result = decompiler.Decompile(stream);
// If the BAML root names a CLR partial-class type, prefer the type's reflection name
// for the .xaml file so it lines up with the matching .xaml.cs the C# project writer
// emits. Otherwise just swap extensions on the existing resource name.
var typeDefinition = result.TypeName.HasValue
? typeSystem.MainModule.GetTypeDefinition(result.TypeName.Value.TopLevelTypeName)
: null;
if (typeDefinition != null)
{
fileName = WholeProjectDecompiler.SanitizeFileName(typeDefinition.ReflectionName + ".xaml");
var partialTypeInfo = new PartialTypeInfo(typeDefinition);
foreach (var member in result.GeneratedMembers)
partialTypeInfo.AddDeclaredMember(member);
context.AddPartialTypeInfo(partialTypeInfo);
}
else
{
fileName = Path.ChangeExtension(fileName, ".xaml");
}
context.AdditionalProperties.Add("Generator", "MSBuild:Compile");
context.AdditionalProperties.Add("SubType", "Designer");
var saveFileName = Path.Combine(context.DecompilationOptions.SaveAsProjectDirectory!, fileName);
Directory.CreateDirectory(Path.GetDirectoryName(saveFileName)!);
result.Xaml.Save(saveFileName);
return fileName;
}
}
}
Loading…
Cancel
Save