Fix #3952: decompile VB.NET anonymous types and queries to valid C# - #3956
Open
siegfriedpammer wants to merge 6 commits into
Open
Fix #3952: decompile VB.NET anonymous types and queries to valid C##3956siegfriedpammer wants to merge 6 commits into
siegfriedpammer wants to merge 6 commits into
Conversation
siegfriedpammer
commented
Aug 5, 2026
siegfriedpammer
force-pushed
the
fix-3952-vb-anonymous-types
branch
3 times, most recently
from
August 7, 2026 07:10
98440f8 to
39725bb
Compare
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
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
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
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
siegfriedpammer
force-pushed
the
fix-3952-vb-anonymous-types
branch
from
August 7, 2026 07:11
39725bb to
49c282a
Compare
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3952.
The bug
Two predicates disagreed about what a compiler-generated name looks like:
SRMExtensions.IsGeneratedName(metadata level) counts a$in the name, soSRMExtensions.IsAnonymousTypematchedVB$AnonymousType_*andMemberIsHiddendropped the definitions from the output.
NRExtensions.HasGeneratedName(IType)(type system level) only looked for<, soNRExtensions.IsAnonymousTypereturned false and none of the anonymous-typetranslations in
CallBuilder/ExpressionBuilderfired.VB assemblies therefore lost the type definitions and kept the raw metadata names at
every use site — output that cannot be recompiled, since
$is not a legal C# identifiercharacter.
The fix
metadata-level behaviour is kept exactly as it was: counting every name that merely
contains
<would newly capture explicit implementations of generic interface members.$VB$It,$VB$It1,$VB$It2and$VB$ItAnonymous(Roslyn'sGeneratedNameConstants),the counterpart to C#'s
<>h__TransparentIdentifier. Unrecognized, they survived intothe output and left the queries uncompilable even once the anonymous types were fixed.
Without the second change the first one alone still yields invalid identifiers for every VB
query with more than one range variable, which is the shape the reported assembly is full of.
Effect
A VB
From i In items Let square = i * i Where square > 4 Select i, squarewent fromto
On the assembly from the issue (
QuartzNetWebConsole.Views1.0.2) everyVB$AnonymousType_*reference is gone.
Tests
New
VBPretty/VBAnonymousTypesfixture (20 configurations) covering mutable andKeyanonymous types, an anonymous type as an argument, and
Select/Let+Where/Join/Order Byqueries. Committed fixtures-first: the expected output fails on the first commit and passes on
the last. Full
ICSharpCode.Decompiler.Testssuite green (3314 passed, 46 skipped, 0 failed).The Roslyn 2.10 / .NET Core 2.2 configuration is branched with
#if: there the query operatorcalls are not restored to extension-method syntax, so no query expression is formed at all.
That is a pre-existing, config-specific gap unrelated to this change.
Deliberately not in scope
VB$AnonymousDelegate_*types (already called out in the issue as separate work). Theseare not a VB-only construct: a natural-typed C# lambda or method group that
Func/Actioncannot express makes Roslyn synthesize a delegate type too, and decompiling those is what
the
natural-type-lambdas-methodsbranch adds. ItsIsAnonymousDelegatepredicate matcheson
Name.Contains("AnonymousDelegate")through the sameHasGeneratedName()this PR fixes,and VB's synthesized delegates satisfy its other conditions as well (empty namespace,
TypeKind.Delegate,[CompilerGenerated]), so the two compose: this PR is what makes thatmachinery reachable for VB rather than something that has to be duplicated for it.
with statement bodies (common in VB XML-literal code). This is not VB-specific: with
QueryExpressionsdisabled, C# output emits 35<>h__TransparentIdentifierreferencesfor the existing
QueryExpressionsfixture today. ILSpy appliesEscapeInvalidIdentifiersonly in the test harness, not in the default pipeline.
_Closure$__N-M,$VB$Local_*) are still not recognized assuch.
This pull request was prepared by an AI agent (Claude) on Siegfried's behalf.