diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index b984447c2..a75d1dfd7 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -98,6 +98,7 @@ + @@ -143,6 +144,7 @@ + diff --git a/ICSharpCode.Decompiler.Tests/IsolatedMethodDecompilationTests.cs b/ICSharpCode.Decompiler.Tests/IsolatedMethodDecompilationTests.cs new file mode 100644 index 000000000..7fbf62a99 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/IsolatedMethodDecompilationTests.cs @@ -0,0 +1,73 @@ +// 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.IO; +using System.Linq; +using System.Reflection.PortableExecutable; +using System.Threading.Tasks; + +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.Tests.Helpers; +using ICSharpCode.Decompiler.TypeSystem; + +using NUnit.Framework; + +namespace ICSharpCode.Decompiler.Tests +{ + /// + /// Decompiling a single member in isolation (the "IL with C#" view does this, decompiling one + /// method handle at a time) gives the transforms only a partial syntax tree. This is the mirror + /// image of the whole-file ILPretty tests: same assembled-IL fixture, but the assertion is about + /// one method's output rather than the full module. + /// + [TestFixture] + public class IsolatedMethodDecompilationTests + { + static readonly string TestCasePath = Tester.TestCasePath + "/IsolatedDecompilation"; + + [Test] + public async Task StaticConstructorKeepsFieldInitializers() + { + var ilFile = Path.Combine(TestCasePath, "IsolatedStaticCtor.il"); + var assembly = await Tester.AssembleIL(ilFile, AssemblerOptions.Library).ConfigureAwait(false); + + var settings = new DecompilerSettings(); + using var file = new FileStream(assembly, FileMode.Open, FileAccess.Read); + var module = new PEFile(assembly, file, PEStreamOptions.PrefetchEntireImage); + var targetFramework = module.Metadata.DetectTargetFrameworkId(); + var resolver = new UniversalAssemblyResolver(assembly, false, targetFramework, null, PEStreamOptions.PrefetchMetadata); + resolver.AddSearchDirectory(Tester.RefAssembliesToolset.GetPath(targetFramework)); + var typeSystem = new DecompilerTypeSystem(module, resolver, settings); + var decompiler = new CSharpDecompiler(typeSystem, settings); + + var staticCtor = typeSystem.MainModule.TypeDefinitions + .Single(t => t.Name == "C") + .Methods.Single(m => m.IsStatic && m.IsConstructor); + + // Decompile just the .cctor handle: the type's field declarations are not part of the + // resulting syntax tree, so the static field initializers have nowhere to move to. + var code = decompiler.Decompile(staticCtor.MetadataToken).ToString(); + + // They must therefore remain as assignments in the constructor body rather than being + // dropped (#3774). + Assert.That(code, Does.Contain("Number = 42")); + Assert.That(code, Does.Contain("Text = \"hello\"")); + } + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/IsolatedDecompilation/.gitignore b/ICSharpCode.Decompiler.Tests/TestCases/IsolatedDecompilation/.gitignore new file mode 100644 index 000000000..d35b79a63 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/IsolatedDecompilation/.gitignore @@ -0,0 +1,2 @@ +/*.dll +/*.pdb diff --git a/ICSharpCode.Decompiler.Tests/TestCases/IsolatedDecompilation/IsolatedStaticCtor.il b/ICSharpCode.Decompiler.Tests/TestCases/IsolatedDecompilation/IsolatedStaticCtor.il new file mode 100644 index 000000000..a494a44ae --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/IsolatedDecompilation/IsolatedStaticCtor.il @@ -0,0 +1,36 @@ +#define CORE_ASSEMBLY "System.Runtime" + +// Regression fixture for #3774. Static field initializers compile into the type's static +// constructor, and the decompiler normally moves them onto the field declarations. When a single +// .cctor is decompiled in isolation (as the "IL with C#" view does, via CSharpDecompiler.Decompile +// of one method handle) those declarations are absent from the partial syntax tree, so the +// assignments must stay in the constructor body instead of being dropped. The type is +// 'beforefieldinit' -- the configuration under which the decompiler treats the .cctor stores as +// movable field initializers, which is what triggered the dropped statement. + +.assembly extern CORE_ASSEMBLY +{ + .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) + .ver 4:0:0:0 +} + +.assembly IsolatedStaticCtor { } + +.class public auto ansi beforefieldinit C + extends [CORE_ASSEMBLY]System.Object +{ + .field public static initonly int32 Number + .field public static initonly string Text + + .method private hidebysig specialname rtspecialname static + void .cctor () cil managed + { + .maxstack 8 + + ldc.i4.s 42 + stsfld int32 C::Number + ldstr "hello" + stsfld string C::Text + ret + } +} diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/TransformFieldAndConstructorInitializers.cs b/ICSharpCode.Decompiler/CSharp/Transforms/TransformFieldAndConstructorInitializers.cs index 2bc98bbcf..c3b1244c3 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/TransformFieldAndConstructorInitializers.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/TransformFieldAndConstructorInitializers.cs @@ -497,8 +497,13 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms if (!MemberToDeclaringSyntaxNodeMap.TryGetValue(member, out var declaringSyntaxNode)) { - Debug.Assert(kind is InitializerKind.Primary); - stmt.Remove(); + // A primary-constructor parameter assignment whose backing member has no separate + // declaration is redundant and dropped. For static/instance initializers a missing + // declaration instead means the member is not part of this (partial) syntax tree -- + // e.g. when a single static constructor is decompiled in isolation -- so the + // assignment must remain in the constructor body. + if (kind is InitializerKind.Primary) + stmt.Remove(); continue; }