diff --git a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs index 5cb703218..542774548 100644 --- a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs @@ -387,23 +387,26 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler string GetFileFileNameForHandle(TypeDefinitionHandle h) { - var type = metadata.GetTypeDefinition(h); - string file = CleanUpFileName(metadata.GetString(type.Name), ".cs"); - string ns = metadata.GetString(type.Namespace); - if (string.IsNullOrEmpty(ns)) - { - return file; - } - else + // A code-behind class belongs to the document it completes: WPF tooling expects + // MainWindow.xaml.cs beside MainWindow.xaml, and treats a stray MainWindow.cs + // elsewhere in the tree as an unrelated file. + foreach (var partialType in partialTypes) { - string dir = Settings.UseNestedDirectoriesForNamespaces ? CleanUpPath(ns) : CleanUpDirectoryName(ns); - if (directories.Add(dir)) + if (partialType.DeclaringTypeDefinitionHandle == h && partialType.CompanionFileName != null) { - var path = Path.Combine(TargetDirectory, dir); - CreateDirectory(path); + string companionDirectory = Path.GetDirectoryName(partialType.CompanionFileName)!; + if (!string.IsNullOrEmpty(companionDirectory) && directories.Add(companionDirectory)) + CreateDirectory(Path.Combine(TargetDirectory, companionDirectory)); + return partialType.CompanionFileName + ".cs"; } - return Path.Combine(dir, file); } + + var type = metadata.GetTypeDefinition(h); + string fileName = GetFileNameForType(metadata.GetString(type.Namespace), metadata.GetString(type.Name), ".cs"); + string directory = Path.GetDirectoryName(fileName)!; + if (!string.IsNullOrEmpty(directory) && directories.Add(directory)) + CreateDirectory(Path.Combine(TargetDirectory, directory)); + return fileName; } void ProcessFiles(List> files) @@ -972,6 +975,30 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler /// Removes invalid characters from file names and reduces their length, /// but keeps file extensions and path structure intact. /// + /// + /// The path of a file belonging to a type: the namespace becomes directories or one + /// flattened directory name, depending on + /// . Everything a type + /// owns - its C# file and the XAML document it is the code-behind of - goes here, so the + /// two end up next to each other. + /// + public static string GetFileNameForType(string @namespace, string typeName, string extension, + bool useNestedDirectoriesForNamespaces) + { + string file = CleanUpFileName(typeName, extension); + if (string.IsNullOrEmpty(@namespace)) + return file; + string directory = useNestedDirectoriesForNamespaces + ? CleanUpPath(@namespace) + : CleanUpDirectoryName(@namespace); + return Path.Combine(directory, file); + } + + protected string GetFileNameForType(string @namespace, string typeName, string extension) + { + return GetFileNameForType(@namespace, typeName, extension, Settings.UseNestedDirectoriesForNamespaces); + } + public static string SanitizeFileName(string fileName) { return CleanUpName(fileName, separateAtDots: false, treatAsFileName: true, treatAsPath: true); diff --git a/ICSharpCode.Decompiler/PartialTypeInfo.cs b/ICSharpCode.Decompiler/PartialTypeInfo.cs index 27564d4fd..27775e2d3 100644 --- a/ICSharpCode.Decompiler/PartialTypeInfo.cs +++ b/ICSharpCode.Decompiler/PartialTypeInfo.cs @@ -16,6 +16,8 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +#nullable enable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -43,6 +45,13 @@ namespace ICSharpCode.Decompiler public TypeDefinitionHandle DeclaringTypeDefinitionHandle { get; } + /// + /// The document this type is the code-behind of, as a project-relative path + /// ("Views/MainWindow.xaml"), where there is one. The project decompiler names the type's + /// C# file after it, so that the two sit next to each other the way the tooling expects. + /// + public string? CompanionFileName { get; set; } + public void AddDeclaredMember(IMember member) { declaredMembers.Add(member.MetadataToken); diff --git a/ICSharpCode.ILSpyCmd.Tests/BamlFixtureTypes.cs b/ICSharpCode.ILSpyCmd.Tests/BamlFixtureTypes.cs new file mode 100644 index 000000000..859114e59 --- /dev/null +++ b/ICSharpCode.ILSpyCmd.Tests/BamlFixtureTypes.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// 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. + +namespace ICSharpCode.ILSpyCmd.Tests.Views +{ + /// + /// Plays the role of a WPF code-behind class: the BAML fixture "views/deeppage.baml" names it + /// as its root, which is how the BAML decompiler recognises an x:Class type. + /// + public class DeepPage + { + } +} diff --git a/ICSharpCode.ILSpyCmd.Tests/ProjectExportBamlTests.cs b/ICSharpCode.ILSpyCmd.Tests/ProjectExportBamlTests.cs index 08f1a886b..9449ef325 100644 --- a/ICSharpCode.ILSpyCmd.Tests/ProjectExportBamlTests.cs +++ b/ICSharpCode.ILSpyCmd.Tests/ProjectExportBamlTests.cs @@ -80,6 +80,32 @@ namespace ICSharpCode.ILSpyCmd.Tests }); } + [Test] + public async Task TheCodeBehindSitsNextToItsDocument() + { + // WPF tooling pairs MainWindow.xaml with MainWindow.xaml.cs by name and location; a + // code-behind anywhere else is an unrelated file as far as the project is concerned. + await RunAsync(testAssemblyPath, "--disable-updatecheck", "-p", "-o", outputDirectory); + + string documentDirectory = Path.Combine(outputDirectory, "ICSharpCode.ILSpyCmd.Tests.Views"); + Assert.Multiple(() => { + Assert.That(File.Exists(Path.Combine(documentDirectory, "DeepPage.xaml")), Is.True, "the document"); + Assert.That(File.Exists(Path.Combine(documentDirectory, "DeepPage.xaml.cs")), Is.True, "its code-behind"); + }); + } + + [Test] + public async Task DocumentsFollowTheNamespaceDirectoriesToo() + { + await RunAsync(testAssemblyPath, "--disable-updatecheck", "-p", "--nested-directories", "-o", outputDirectory); + + string documentDirectory = Path.Combine(outputDirectory, "ICSharpCode", "ILSpyCmd", "Tests", "Views"); + Assert.Multiple(() => { + Assert.That(File.Exists(Path.Combine(documentDirectory, "DeepPage.xaml")), Is.True, "the document"); + Assert.That(File.Exists(Path.Combine(documentDirectory, "DeepPage.xaml.cs")), Is.True, "its code-behind"); + }); + } + [Test] public async Task TheOldOptInFlagStillWorks() { diff --git a/ICSharpCode.ILSpyCmd.Tests/fixtures/test.g.resources b/ICSharpCode.ILSpyCmd.Tests/fixtures/test.g.resources index aadddbfe2..51c8b22a9 100644 Binary files a/ICSharpCode.ILSpyCmd.Tests/fixtures/test.g.resources and b/ICSharpCode.ILSpyCmd.Tests/fixtures/test.g.resources differ diff --git a/ICSharpCode.ILSpyCmd/BamlAwareWholeProjectDecompiler.cs b/ICSharpCode.ILSpyCmd/BamlAwareWholeProjectDecompiler.cs index 4f450a35f..30aac5de3 100644 --- a/ICSharpCode.ILSpyCmd/BamlAwareWholeProjectDecompiler.cs +++ b/ICSharpCode.ILSpyCmd/BamlAwareWholeProjectDecompiler.cs @@ -67,8 +67,10 @@ namespace ICSharpCode.ILSpyCmd : null; if (typeDefinition != null) { - xamlFileName = SanitizeFileName(typeDefinition.ReflectionName + ".xaml"); - partialTypeInfo = new PartialTypeInfo(typeDefinition); + // Next to where the type's own C# file goes, so that the code-behind can be named + // after the document and land beside it. + xamlFileName = GetFileNameForType(typeDefinition.Namespace, typeDefinition.Name, ".xaml"); + partialTypeInfo = new PartialTypeInfo(typeDefinition) { CompanionFileName = xamlFileName }; foreach (var member in result.GeneratedMembers) partialTypeInfo.AddDeclaredMember(member); } diff --git a/ILSpy/TreeNodes/BamlResourceNodeFactory.cs b/ILSpy/TreeNodes/BamlResourceNodeFactory.cs index def7ad3b9..5a4071eb3 100644 --- a/ILSpy/TreeNodes/BamlResourceNodeFactory.cs +++ b/ILSpy/TreeNodes/BamlResourceNodeFactory.cs @@ -79,16 +79,17 @@ namespace ICSharpCode.ILSpy.Baml 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. + // If the BAML root names a CLR partial-class type, the document goes where that type's + // own C# file goes, and the code-behind is then named after the document and lands + // beside it. 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); + fileName = WholeProjectDecompiler.GetFileNameForType(typeDefinition.Namespace, typeDefinition.Name, ".xaml", + context.DecompilationOptions.DecompilerSettings.UseNestedDirectoriesForNamespaces); + var partialTypeInfo = new PartialTypeInfo(typeDefinition) { CompanionFileName = fileName }; foreach (var member in result.GeneratedMembers) partialTypeInfo.AddDeclaredMember(member); context.AddPartialTypeInfo(partialTypeInfo);