Fix #3952: decompile VB.NET anonymous types and queries to valid C# - #3956
Open
siegfriedpammer wants to merge 3 commits into
Open
Fix #3952: decompile VB.NET anonymous types and queries to valid C##3956siegfriedpammer wants to merge 3 commits into
siegfriedpammer wants to merge 3 commits into
Conversation
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
siegfriedpammer
commented
Aug 5, 2026
| { | ||
| return identifier.StartsWith("<>", StringComparison.Ordinal) | ||
| && (identifier.Contains("TransparentIdentifier") || identifier.Contains("TranspIdent")); | ||
| return (identifier.StartsWith("<>", StringComparison.Ordinal) |
Member
Author
There was a problem hiding this comment.
Use if + return for readability
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.