From 89b857ace2c599530202ff3b866c68d000f9f9ca Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 5 Aug 2026 14:36:00 +0200 Subject: [PATCH 1/6] Add VBPretty fixtures for VB anonymous types and queries VBPretty had no coverage of VB's anonymous types, so nothing caught that their use sites decompiled to the raw metadata names while their definitions were hidden from the output. The expected C# is written as it should read once the generated-name predicates agree with each other; it fails until then. Roslyn 2.10 targeting .NET Core 2.2 is branched off with #if: there the query operator calls are not restored to extension-method syntax, so no query expression is formed and the lowered form survives. Assisted-by: Claude:claude-fable-5:Claude Code --- .../ICSharpCode.Decompiler.Tests.csproj | 1 + .../TestCases/VBPretty/VBAnonymousTypes.cs | 89 +++++++++++++++++++ .../TestCases/VBPretty/VBAnonymousTypes.vb | 62 +++++++++++++ .../VBPrettyTestRunner.cs | 7 ++ 4 files changed, 159 insertions(+) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.vb diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index c936871452..29015d86ae 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -149,6 +149,7 @@ + diff --git a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs new file mode 100644 index 0000000000..cc35f4ead5 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +using Microsoft.VisualBasic.CompilerServices; + +[StandardModule] +public sealed class VBAnonymousTypes +{ + public static void MutableAnonymousType() + { + var anon = new { + Value = 1, + Name = "test" + }; + Console.WriteLine(anon.Value); + Console.WriteLine(anon.Name); + } + + public static void KeyAnonymousType() + { + var anon = new { + Value = 1, + Name = "test" + }; + Console.WriteLine(anon.Value); + Console.WriteLine(anon.Name); + } + + public static void AnonymousTypeAsArgument() + { + Console.WriteLine(new { + Value = 1, + Name = "test" + }.ToString()); + } + + public static void SelectAnonymousType(IEnumerable items) + { + var enumerable = items.Select([SpecialName] (int i) => new { + Value = i, + Square = checked(i * i) + }); + foreach (var item in enumerable) + { + Console.WriteLine(item.Value); + Console.WriteLine(item.Square); + } + } + + public static void LetWhereSelect(IEnumerable items) + { + var enumerable = from i in items + let square = checked(i * i) + where square > 4 + select new { i, square }; + foreach (var item in enumerable) + { + Console.WriteLine(item.i); + Console.WriteLine(item.square); + } + } + + public static void JoinSelect(IEnumerable left, IEnumerable right) + { + var enumerable = from x in left + join y in right on x equals y + select new { x, y }; + foreach (var item in enumerable) + { + Console.WriteLine(item.x); + Console.WriteLine(item.y); + } + } + + public static void OrderBySelect(IEnumerable items) + { + var enumerable = from i in items + let doubled = checked(i * 2) + orderby doubled + select new { i, doubled }; + foreach (var item in enumerable) + { + Console.WriteLine(item.i); + Console.WriteLine(item.doubled); + } + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.vb b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.vb new file mode 100644 index 0000000000..b789de35e3 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.vb @@ -0,0 +1,62 @@ +Imports System +Imports System.Collections.Generic +Imports System.Linq + +Public Module VBAnonymousTypes + Public Sub MutableAnonymousType() + Dim value = New With {.Value = 1, .Name = "test"} + Console.WriteLine(value.Value) + Console.WriteLine(value.Name) + End Sub + + Public Sub KeyAnonymousType() + Dim value = New With {Key .Value = 1, Key .Name = "test"} + Console.WriteLine(value.Value) + Console.WriteLine(value.Name) + End Sub + + Public Sub AnonymousTypeAsArgument() + Console.WriteLine(New With {Key .Value = 1, Key .Name = "test"}.ToString()) + End Sub + + Public Sub SelectAnonymousType(items As IEnumerable(Of Integer)) + Dim query = From i In items + Select New With {Key .Value = i, Key .Square = i * i} + For Each item In query + Console.WriteLine(item.Value) + Console.WriteLine(item.Square) + Next + End Sub + + Public Sub LetWhereSelect(items As IEnumerable(Of Integer)) + Dim query = From i In items + Let square = i * i + Where square > 4 + Select i, square + For Each item In query + Console.WriteLine(item.i) + Console.WriteLine(item.square) + Next + End Sub + + Public Sub JoinSelect(left As IEnumerable(Of Integer), right As IEnumerable(Of Integer)) + Dim query = From x In left + Join y In right On x Equals y + Select x, y + For Each item In query + Console.WriteLine(item.x) + Console.WriteLine(item.y) + Next + End Sub + + Public Sub OrderBySelect(items As IEnumerable(Of Integer)) + Dim query = From i In items + Let doubled = i * 2 + Order By doubled + Select i, doubled + For Each item In query + Console.WriteLine(item.i) + Console.WriteLine(item.doubled) + Next + End Sub +End Module diff --git a/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs index ff2feb797c..3cd9f3b09b 100644 --- a/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs @@ -125,6 +125,13 @@ public async Task Select([ValueSource(nameof(defaultOptions))] CompilerOptions o await Run(options: options | CompilerOptions.Library); } + [Test] + public async Task VBAnonymousTypes([ValueSource(nameof(defaultOptions))] CompilerOptions options) + { + IgnoreIfVbRuntimeSubstituted(options); + await Run(options: options | CompilerOptions.Library); + } + [Test] public async Task Issue1906([ValueSource(nameof(defaultOptions))] CompilerOptions options) { From cecbcb4ec771045e8e75fa62896fc5fc3eb7ff9b Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 5 Aug 2026 14:37:00 +0200 Subject: [PATCH 2/6] Fix #3952: recognize VB-generated names as anonymous types Two predicates disagreed on what a generated name looks like. At the metadata level a '$' in the name counts, so MemberIsHidden treated VB$AnonymousType_0 as an anonymous type and dropped its definition from the output. At the type system level only '<' counted, so none of the anonymous-type translations in CallBuilder and ExpressionBuilder fired. VB assemblies therefore lost the definitions and kept the raw metadata names at every use site, which is not valid C#. Both levels now share one predicate and cannot drift apart again. It keeps the metadata-level behaviour exactly: counting every name that merely contains '<' would newly capture explicit implementations of generic interface members. Assisted-by: Claude:claude-fable-5:Claude Code --- ICSharpCode.Decompiler/NRExtensions.cs | 2 +- ICSharpCode.Decompiler/SRMExtensions.cs | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ICSharpCode.Decompiler/NRExtensions.cs b/ICSharpCode.Decompiler/NRExtensions.cs index db299ee9aa..f3cc16fc50 100644 --- a/ICSharpCode.Decompiler/NRExtensions.cs +++ b/ICSharpCode.Decompiler/NRExtensions.cs @@ -50,7 +50,7 @@ public static bool HasGeneratedName(this IMember member) public static bool HasGeneratedName(this IType type) { - return type.Name.StartsWith("<", StringComparison.Ordinal) || type.Name.Contains("<"); + return SRMExtensions.IsGeneratedName(type.Name); } public static bool IsAnonymousType(this IType type) diff --git a/ICSharpCode.Decompiler/SRMExtensions.cs b/ICSharpCode.Decompiler/SRMExtensions.cs index 2be174f6c0..bcd544fb13 100644 --- a/ICSharpCode.Decompiler/SRMExtensions.cs +++ b/ICSharpCode.Decompiler/SRMExtensions.cs @@ -489,9 +489,20 @@ public static bool IsAnonymousType(this TypeDefinition type, MetadataReader meta public static bool IsGeneratedName(this StringHandle handle, MetadataReader metadata) { - return !handle.IsNil - && (metadata.GetString(handle).StartsWith("<", StringComparison.Ordinal) - || metadata.GetString(handle).Contains("$")); + return !handle.IsNil && IsGeneratedName(metadata.GetString(handle)); + } + + /// + /// Detects the mangled names compilers give to entities that have no user-written + /// declaration. The C# compiler prefixes them with '<', the VB compiler separates + /// the parts with '$' (VB$AnonymousType_0, VB$StateMachine_1_Foo). Neither character + /// is legal in a C# or VB identifier. + /// Note that a name may legitimately contain '<' without being generated: explicit + /// implementations of generic interface members are named after the interface. + /// + internal static bool IsGeneratedName(string name) + { + return name.StartsWith("<", StringComparison.Ordinal) || name.Contains("$"); } public static bool HasGeneratedName(this MethodDefinitionHandle handle, MetadataReader metadata) From 96debd39cf407b9ad1a30ac25555bb9f93f74694 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 5 Aug 2026 14:37:21 +0200 Subject: [PATCH 3/6] Recognize VB transparent identifiers in query decompilation The VB compiler carries the range variables of a query in $VB$It, $VB$It1, $VB$It2 and $VB$ItAnonymous, its counterpart to C#'s <>h__TransparentIdentifier. Unrecognized, they were left in place by CombineQueryExpressions, and since '$' is not legal in a C# identifier every VB query with more than one range variable decompiled to code that cannot be recompiled. Assisted-by: Claude:claude-fable-5:Claude Code --- ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index 3acaceccd8..566e698d2f 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -645,8 +645,13 @@ static bool IsClosureType(SRM.TypeDefinition type, MetadataReader metadata) internal static bool IsTransparentIdentifier(string identifier) { - return identifier.StartsWith("<>", StringComparison.Ordinal) - && (identifier.Contains("TransparentIdentifier") || identifier.Contains("TranspIdent")); + if (identifier.StartsWith("<>", StringComparison.Ordinal)) + { + return identifier.Contains("TransparentIdentifier") || identifier.Contains("TranspIdent"); + } + // The VB compiler names the carriers of its query range variables + // $VB$It, $VB$It1, $VB$It2 and $VB$ItAnonymous. + return identifier.StartsWith("$VB$It", StringComparison.Ordinal); } #endregion From 49c282a7ddac37469cee8a15aa27574e4879b069 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 6 Aug 2026 20:58:08 +0200 Subject: [PATCH 4/6] Declare VB anonymous types that C# anonymous types cannot express A C# anonymous type is immutable and compares every member. VB's are neither unless every property is declared 'Key': otherwise the properties are settable and only the 'Key' ones take part in Equals and GetHashCode. Writing such a type as 'new { ... }' silently gave it value equality and made any assignment to one of its properties fail to compile, so only an anonymous type with no settable property is treated as one; the rest keep their own declaration. Those declarations carry the shape VB gave them, so the round-trip preserves both mutability and 'Key' equality. Their names are the remaining obstacle, since the VB compiler separates the parts with '$': the type, its backing fields and any local named after it are renamed to use '_' instead, and a comment on the declaration says why the type is spelled out. Generated variable names are now rejected when they would not be legal C# identifiers, which also stops a display class from lending its unspeakable name to a local in the NoLocalFunctions output. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Ugly/NoLocalFunctions.Expected.cs | 12 +-- .../TestCases/VBPretty/VBAnonymousTypes.cs | 62 +++++++++++++-- .../CSharp/CSharpDecompiler.cs | 1 + .../RenameVisualBasicAnonymousTypes.cs | 77 +++++++++++++++++++ .../IL/Transforms/AssignVariableNames.cs | 6 ++ ICSharpCode.Decompiler/NRExtensions.cs | 32 +++++++- ICSharpCode.Decompiler/SRMExtensions.cs | 16 +++- 7 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 ICSharpCode.Decompiler/CSharp/Transforms/RenameVisualBasicAnonymousTypes.cs diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoLocalFunctions.Expected.cs b/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoLocalFunctions.Expected.cs index 0d6eecae98..a3f374d42d 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoLocalFunctions.Expected.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoLocalFunctions.Expected.cs @@ -44,9 +44,9 @@ private static void UseLocalFunctionReference() private static void SimpleCapture() { - _003C_003Ec__DisplayClass1_0 _003C_003Ec__DisplayClass1_1 = default(_003C_003Ec__DisplayClass1_0); - _003C_003Ec__DisplayClass1_1.x = 1; - _003CSimpleCapture_003Eg__F_007C1_0(ref _003C_003Ec__DisplayClass1_1); + _003C_003Ec__DisplayClass1_0 obj = default(_003C_003Ec__DisplayClass1_0); + obj.x = 1; + _003CSimpleCapture_003Eg__F_007C1_0(ref obj); } private static void SimpleCaptureWithRef() @@ -56,9 +56,9 @@ private static void SimpleCaptureWithRef() obj.x = 1; new Handle(new Func(obj._003CSimpleCaptureWithRef_003Eg__F_007C0)); #else - _003C_003Ec__DisplayClass2_0 _003C_003Ec__DisplayClass2_1 = new _003C_003Ec__DisplayClass2_0(); - _003C_003Ec__DisplayClass2_1.x = 1; - Handle handle = new Handle(new Func(_003C_003Ec__DisplayClass2_1._003CSimpleCaptureWithRef_003Eg__F_007C0)); + _003C_003Ec__DisplayClass2_0 obj = new _003C_003Ec__DisplayClass2_0(); + obj.x = 1; + Handle handle = new Handle(new Func(obj._003CSimpleCaptureWithRef_003Eg__F_007C0)); #endif } diff --git a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs index cc35f4ead5..fe52dba893 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs @@ -1,21 +1,71 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using Microsoft.VisualBasic.CompilerServices; +// A VB anonymous type. Its properties are settable and only those declared 'Key' +// take part in Equals and GetHashCode, so it cannot be written as a C# anonymous +// type and is declared here instead. +[CompilerGenerated] +[DebuggerDisplay("Value={Value}, Name={Name}")] +internal sealed class VB_AnonymousType_0 +{ +#if !OPT + [DebuggerBrowsable(DebuggerBrowsableState.Never)] +#endif + private T0 _Value; + +#if !OPT + [DebuggerBrowsable(DebuggerBrowsableState.Never)] +#endif + private T1 _Name; + + public T0 Value { + get { + return _Value; + } + set { + _Value = value; + } + } + + public T1 Name { + get { + return _Name; + } + set { + _Name = value; + } + } + +#if !OPT + [DebuggerHidden] +#endif + public VB_AnonymousType_0(T0 Value, T1 Name) + { + _Value = Value; + _Name = Name; + } + +#if !OPT + [DebuggerHidden] +#endif + public override string ToString() + { + return string.Format(null, "{{ Value = {0}, Name = {1} }}", new object[2] { _Value, _Name }); + } +} [StandardModule] public sealed class VBAnonymousTypes { public static void MutableAnonymousType() { - var anon = new { - Value = 1, - Name = "test" - }; - Console.WriteLine(anon.Value); - Console.WriteLine(anon.Name); + VB_AnonymousType_0 obj = new VB_AnonymousType_0(1, "test"); + Console.WriteLine(obj.Value); + Console.WriteLine(obj.Name); } public static void KeyAnonymousType() diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index 566e698d2f..b613b71f50 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -250,6 +250,7 @@ public static List GetAstTransforms() new CombineQueryExpressions(), new NormalizeBlockStatements(), new FlattenSwitchBlocks(), + new RenameVisualBasicAnonymousTypes(), // must run before FixNameCollisions new FixNameCollisions(), new AddXmlDocumentationTransform(), }; diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/RenameVisualBasicAnonymousTypes.cs b/ICSharpCode.Decompiler/CSharp/Transforms/RenameVisualBasicAnonymousTypes.cs new file mode 100644 index 0000000000..9566f67afa --- /dev/null +++ b/ICSharpCode.Decompiler/CSharp/Transforms/RenameVisualBasicAnonymousTypes.cs @@ -0,0 +1,77 @@ +// 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. + +using System.Linq; + +using ICSharpCode.Decompiler.CSharp.Syntax; +using ICSharpCode.Decompiler.TypeSystem; + +namespace ICSharpCode.Decompiler.CSharp.Transforms +{ + /// + /// Gives the anonymous types of a VB assembly that are declared rather than written as C# + /// anonymous types a name that is legal in C#. + /// + /// + /// An anonymous type with a settable property has no C# anonymous type equivalent + /// (see ), so its declaration is emitted and + /// referred to by name. The VB compiler separates the parts of that name with '$', which is + /// not a legal identifier character in C#, and does the same for the backing fields. + /// Renaming happens per identifier via its symbol, so a reference is renamed the same way + /// whether or not the declaration is part of the same output. + /// + public class RenameVisualBasicAnonymousTypes : IAstTransform + { + public void Run(AstNode rootNode, TransformContext context) + { + foreach (var identifier in rootNode.DescendantsAndSelf.OfType()) + { + if (!identifier.Name.Contains("$")) + continue; + // A field declaration carries its symbol on the FieldDeclaration, not on the + // VariableInitializer holding the name. + if (FindEntity(identifier.Parent) is not IEntity entity || entity.Name != identifier.Name) + continue; + var declaringType = entity as ITypeDefinition ?? entity.DeclaringTypeDefinition; + if (declaringType == null || !declaringType.IsAnonymousTypeDeclaredAsNamedType()) + continue; + identifier.Name = identifier.Name.Replace('$', '_'); + } + + foreach (var typeDeclaration in rootNode.DescendantsAndSelf.OfType()) + { + if (typeDeclaration.GetSymbol() is not ITypeDefinition type + || !type.IsAnonymousTypeDeclaredAsNamedType()) + { + continue; + } + typeDeclaration.AddLeadingTrivia(new Comment( + " A VB anonymous type. Its properties are settable and only those declared 'Key'")); + typeDeclaration.AddLeadingTrivia(new Comment( + " take part in Equals and GetHashCode, so it cannot be written as a C# anonymous")); + typeDeclaration.AddLeadingTrivia(new Comment( + " type and is declared here instead.")); + } + + static IEntity FindEntity(AstNode node) + { + return node?.GetSymbol() as IEntity ?? node?.Parent?.GetSymbol() as IEntity; + } + } + } +} diff --git a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs index 9a3cce0c8d..7aba498bce 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs @@ -882,6 +882,12 @@ static string CleanUpVariableName(string name) if (name.Length == 0) return "obj"; + if (!IsValidName(name)) + { + // A name taken from metadata may be legal there but not in C#; VB, for one, + // separates the parts of its generated names with '$'. + return null; + } string lowerCaseName = char.ToLower(name[0]) + name.Substring(1); if (CSharp.OutputVisitor.CSharpOutputVisitor.IsKeyword(lowerCaseName)) return null; diff --git a/ICSharpCode.Decompiler/NRExtensions.cs b/ICSharpCode.Decompiler/NRExtensions.cs index f3cc16fc50..87663b7cb3 100644 --- a/ICSharpCode.Decompiler/NRExtensions.cs +++ b/ICSharpCode.Decompiler/NRExtensions.cs @@ -53,6 +53,36 @@ public static bool HasGeneratedName(this IType type) return SRMExtensions.IsGeneratedName(type.Name); } + /// + /// A C# anonymous type is immutable and compares all of its members, so only an anonymous + /// type with no settable property can be written as one. The C# compiler emits nothing + /// else, but VB's properties are settable unless declared 'Key', and only 'Key' members + /// take part in Equals/GetHashCode: those types keep their own declaration. + /// + static bool HasOnlyReadOnlyProperties(ITypeDefinition type) + { + foreach (var property in type.Properties) + { + if (property.CanSet) + return false; + } + return true; + } + + /// + /// An anonymous type that keeps its own declaration because it cannot be written as a C# + /// anonymous type: a VB anonymous type with at least one settable, non-'Key' property. + /// + public static bool IsAnonymousTypeDeclaredAsNamedType(this ITypeDefinition type) + { + return type != null + && string.IsNullOrEmpty(type.Namespace) + && type.HasGeneratedName() + && (type.Name.Contains("AnonType") || type.Name.Contains("AnonymousType")) + && type.IsCompilerGenerated() + && !HasOnlyReadOnlyProperties(type); + } + public static bool IsAnonymousType(this IType type) { if (type == null) @@ -61,7 +91,7 @@ public static bool IsAnonymousType(this IType type) && (type.Name.Contains("AnonType") || type.Name.Contains("AnonymousType"))) { ITypeDefinition td = type.GetDefinition(); - return td != null && td.IsCompilerGenerated(); + return td != null && td.IsCompilerGenerated() && HasOnlyReadOnlyProperties(td); } return false; } diff --git a/ICSharpCode.Decompiler/SRMExtensions.cs b/ICSharpCode.Decompiler/SRMExtensions.cs index bcd544fb13..dddf0e53a6 100644 --- a/ICSharpCode.Decompiler/SRMExtensions.cs +++ b/ICSharpCode.Decompiler/SRMExtensions.cs @@ -474,13 +474,27 @@ public static FullTypeName GetFullTypeName(this ExportedType type, MetadataReade } } + /// + /// See NRExtensions.HasOnlyReadOnlyProperties: an anonymous type with a settable property + /// cannot be written as a C# anonymous type, so its declaration must not be hidden. + /// + static bool HasOnlyReadOnlyProperties(TypeDefinition type, MetadataReader metadata) + { + foreach (var handle in type.GetProperties()) + { + if (!metadata.GetPropertyDefinition(handle).GetAccessors().Setter.IsNil) + return false; + } + return true; + } + public static bool IsAnonymousType(this TypeDefinition type, MetadataReader metadata) { string name = metadata.GetString(type.Name); if (type.Namespace.IsNil && type.HasGeneratedName(metadata) && (name.Contains("AnonType") || name.Contains("AnonymousType"))) { - return type.IsCompilerGenerated(metadata); + return type.IsCompilerGenerated(metadata) && HasOnlyReadOnlyProperties(type, metadata); } return false; } From 86e4e12f5d1205f72a39602a1db6dc07777e793c Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 09:59:37 +0200 Subject: [PATCH 5/6] Branch the VBAnonymousTypes fixture for legacy vbc codegen The legacy .NET Framework vbc lowers anonymous types differently from Roslyn: ToString builds its result with a StringBuilder instead of one String.Format call, no DebuggerBrowsable/DebuggerHidden attributes are emitted even in debug builds, and in optimized builds the DebuggerDisplay attribute precedes CompilerGenerated in metadata order. The None/Optimize test configurations only run on machines where that compiler is installed, which is why the fixture did not cover them yet. Assisted-by: Claude:claude-fable-5:Claude Code --- .../TestCases/VBPretty/VBAnonymousTypes.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs index fe52dba893..dde61d8b2d 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs @@ -3,22 +3,30 @@ using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; +#if LEGACY_VBC +using System.Text; +#endif using Microsoft.VisualBasic.CompilerServices; // A VB anonymous type. Its properties are settable and only those declared 'Key' // take part in Equals and GetHashCode, so it cannot be written as a C# anonymous // type and is declared here instead. +#if LEGACY_VBC && OPT +[DebuggerDisplay("Value={Value}, Name={Name}")] +[CompilerGenerated] +#else [CompilerGenerated] [DebuggerDisplay("Value={Value}, Name={Name}")] +#endif internal sealed class VB_AnonymousType_0 { -#if !OPT +#if !OPT && !LEGACY_VBC [DebuggerBrowsable(DebuggerBrowsableState.Never)] #endif private T0 _Value; -#if !OPT +#if !OPT && !LEGACY_VBC [DebuggerBrowsable(DebuggerBrowsableState.Never)] #endif private T1 _Name; @@ -41,7 +49,7 @@ public T1 Name { } } -#if !OPT +#if !OPT && !LEGACY_VBC [DebuggerHidden] #endif public VB_AnonymousType_0(T0 Value, T1 Name) @@ -50,12 +58,21 @@ public VB_AnonymousType_0(T0 Value, T1 Name) _Name = Name; } -#if !OPT +#if !OPT && !LEGACY_VBC [DebuggerHidden] #endif public override string ToString() { +#if LEGACY_VBC + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.Append("{ "); + stringBuilder.AppendFormat("{0} = {1}, ", "Value", _Value); + stringBuilder.AppendFormat("{0} = {1} ", "Name", _Name); + stringBuilder.Append("}"); + return stringBuilder.ToString(); +#else return string.Format(null, "{{ Value = {0}, Name = {1} }}", new object[2] { _Value, _Name }); +#endif } } [StandardModule] From 11325352c8bca89a450cb7c446448ba084ec2b5f Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 11:03:15 +0200 Subject: [PATCH 6/6] Stabilize the bookmark-highlight tests against dispatcher stalls The bookmark navigation tests asserted the one-shot line highlight by polling the text view's renderer collection, but the adorner self-dismisses after an ~800 ms lifetime driven by a DispatcherTimer. On a loaded CI runner (the desktop job runs the UI and decompiler test suites concurrently) the dispatcher can stall long enough that the adorner registers and is dismissed again before the test's next predicate check, so the wait misses the entire play and burns its full 60 s timeout; raising the timeout cannot help with that. Record the last played line on DecompilerTextView as persistent evidence of the one-shot highlight and assert that instead - it also pins the highlight to the expected line, which the presence check never did. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Bookmarks/BookmarkNavigationViewTests.cs | 32 +++++++++---------- ILSpy/TextView/DecompilerTextView.axaml.cs | 8 +++++ 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs index 4685b2d012..52e147f18c 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs @@ -23,8 +23,6 @@ using Avalonia.Threading; using Avalonia.VisualTree; -using AvaloniaEdit.Rendering; - using AwesomeAssertions; using ICSharpCode.ILSpy.AppEnv; @@ -97,11 +95,11 @@ public async Task Navigating_From_NonDecompiler_Content_Scrolls_To_And_Highlight await vm.DockWorkspace.WaitForDecompiledTextAsync(); view = await window.WaitForComponent(); - // Wait until the one-shot highlight has registered, then assert without any further delay: the - // adorner self-dismisses after an ~800 ms lifetime, so a fixed-length pump on a loaded CI runner - // can outlast it and observe an empty collection. The same deferred apply also lands the caret. - await Waiters.WaitForAsync(() => view.Editor.TextArea.TextView.BackgroundRenderers - .OfType().Any()); + // Wait until the one-shot highlight has played. The adorner itself self-dismisses after an + // ~800 ms lifetime and a stalled CI dispatcher can register and dismiss it inside a single + // pump, so polling the renderer collection can miss the entire play; LastHighlightPlayedLine + // is the view's persistent record of it. The same deferred apply also lands the caret. + await Waiters.WaitForAsync(() => view.LastHighlightPlayedLine != null); int targetLine = view.GetLineForBookmark(bookmark) ?? -1; targetLine.Should().BeGreaterThan(1, "the bookmark resolves to a line below the top in the freshly shown document"); @@ -110,9 +108,9 @@ await Waiters.WaitForAsync(() => view.Editor.TextArea.TextView.BackgroundRendere view.Editor.TextArea.Caret.Line.Should().Be(targetLine, "bookmark navigation from non-decompiler content must scroll to the saved line"); - // P2: the one-shot line highlight is playing on the freshly shown view. - view.Editor.TextArea.TextView.BackgroundRenderers.OfType() - .Should().ContainSingle("the destination line must be highlighted after the content switch"); + // P2: the one-shot line highlight played on the freshly shown view, on the right line. + view.LastHighlightPlayedLine.Should().Be(targetLine, + "the destination line must be highlighted after the content switch"); } // Regression: when the active tab is frozen, navigating to a bookmark in a different node must @@ -160,21 +158,21 @@ public async Task Navigating_To_Bookmark_With_A_Frozen_Active_Tab_Opens_And_Posi var activeModel = vm.DockWorkspace.ActiveDecompilerTab; activeModel.Should().NotBeNull("navigation must surface a decompiler tab"); - // Wait until the fresh preview's view exists and its one-shot highlight has registered, then - // assert without any further delay: the adorner self-dismisses after an ~800 ms lifetime, so a - // fixed-length pump on a loaded CI runner can outlast it and observe an empty collection. + // Wait until the fresh preview's view exists and its one-shot highlight has played. The + // adorner itself self-dismisses after an ~800 ms lifetime and a stalled CI dispatcher can + // register and dismiss it inside a single pump, so polling the renderer collection can miss + // the entire play; LastHighlightPlayedLine is the view's persistent record of it. DecompilerTextView? ActiveView() => window.GetVisualDescendants().OfType() .FirstOrDefault(v => ReferenceEquals(v.DataContext, activeModel)); - await Waiters.WaitForAsync(() => ActiveView()?.Editor.TextArea.TextView.BackgroundRenderers - .OfType().Any() == true); + await Waiters.WaitForAsync(() => ActiveView()?.LastHighlightPlayedLine != null); var activeView = ActiveView()!; int targetLine = activeView.GetLineForBookmark(bookmark) ?? -1; targetLine.Should().BeGreaterThan(1, "the fresh preview shows System.String with the bookmarked line below the top"); activeView.Editor.TextArea.Caret.Line.Should().Be(targetLine, "opening a fresh preview for a frozen-tab navigation must still scroll to the bookmark"); - activeView.Editor.TextArea.TextView.BackgroundRenderers.OfType() - .Should().ContainSingle("the destination line must be highlighted in the fresh preview"); + activeView.LastHighlightPlayedLine.Should().Be(targetLine, + "the destination line must be highlighted in the fresh preview"); } // Regression: a bookmark re-anchors by token/IL offset, so a decompiler-setting change that diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 4bac40bff1..cd5f39ab88 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -843,6 +843,13 @@ bool ApplyBookmark(Bookmarks.Bookmark bookmark) return false; } + // The last line the one-shot navigation highlight was played on in this view, or null when + // none has played yet. The adorner itself self-dismisses after its ~800 ms lifetime, so an + // observer polling the renderer collection can miss the entire play when the dispatcher + // stalls (a headless test on a loaded CI runner); this record is the persistent evidence + // that the highlight ran, and where. + internal int? LastHighlightPlayedLine { get; private set; } + void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) { var document = Editor.Document; @@ -862,6 +869,7 @@ void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) RestoreBookmarkFoldings(viewState); CenterLineInView(document, line); LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line); + LastHighlightPlayedLine = line; bookmarkMargin?.PulseLine(line); }, DispatcherPriority.Background); }