Skip to content

Fix #3952: decompile VB.NET anonymous types and queries to valid C# - #3956

Open
siegfriedpammer wants to merge 3 commits into
masterfrom
fix-3952-vb-anonymous-types
Open

Fix #3952: decompile VB.NET anonymous types and queries to valid C##3956
siegfriedpammer wants to merge 3 commits into
masterfrom
fix-3952-vb-anonymous-types

Conversation

@siegfriedpammer

@siegfriedpammer siegfriedpammer commented Aug 5, 2026

Copy link
Copy Markdown
Member

Fixes #3952.

The bug

Two predicates disagreed about what a compiler-generated name looks like:

  • SRMExtensions.IsGeneratedName (metadata level) counts a $ in the name, so
    SRMExtensions.IsAnonymousType matched VB$AnonymousType_* and MemberIsHidden
    dropped the definitions from the output.
  • NRExtensions.HasGeneratedName(IType) (type system level) only looked for <, so
    NRExtensions.IsAnonymousType returned false and none of the anonymous-type
    translations in CallBuilder/ExpressionBuilder fired.

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# identifier
character.

The fix

  1. One shared predicate for both levels, so they cannot drift apart again. The
    metadata-level behaviour is kept exactly as it was: counting every name that merely
    contains < would newly capture explicit implementations of generic interface members.
  2. VB transparent identifiers. The VB compiler carries query range variables in
    $VB$It, $VB$It1, $VB$It2 and $VB$ItAnonymous (Roslyn's GeneratedNameConstants),
    the counterpart to C#'s <>h__TransparentIdentifier. Unrecognized, they survived into
    the 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, square went from

IEnumerable<VB$AnonymousType_3<int, int>> enumerable = from i in items
    select new VB$AnonymousType_3<int, int>(i, checked(i * i)) into $VB$It
    where $VB$It.square > 4
    select new VB$AnonymousType_3<int, int>($VB$It.i, $VB$It.square);

to

var enumerable = from i in items
                 let square = checked(i * i)
                 where square > 4
                 select new { i, square };

On the assembly from the issue (QuartzNetWebConsole.Views 1.0.2) every VB$AnonymousType_*
reference is gone.

Tests

New VBPretty/VBAnonymousTypes fixture (20 configurations) covering mutable and Key
anonymous types, an anonymous type as an argument, and Select/Let+Where/Join/Order By
queries. Committed fixtures-first: the expected output fails on the first commit and passes on
the last. Full ICSharpCode.Decompiler.Tests suite green (3314 passed, 46 skipped, 0 failed).

The Roslyn 2.10 / .NET Core 2.2 configuration is branched with #if: there the query operator
calls 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). These
    are not a VB-only construct: a natural-typed C# lambda or method group that Func/Action
    cannot express makes Roslyn synthesize a delegate type too, and decompiling those is what
    the natural-type-lambdas-methods branch adds. Its IsAnonymousDelegate predicate matches
    on Name.Contains("AnonymousDelegate") through the same HasGeneratedName() 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 that
    machinery reachable for VB rather than something that has to be duplicated for it.
  • Transparent identifiers that survive because the query transform bails, e.g. for lambdas
    with statement bodies (common in VB XML-literal code). This is not VB-specific: with
    QueryExpressions disabled, C# output emits 35 <>h__TransparentIdentifier references
    for the existing QueryExpressions fixture today. ILSpy applies EscapeInvalidIdentifiers
    only in the test harness, not in the default pipeline.
  • VB closure display classes (_Closure$__N-M, $VB$Local_*) are still not recognized as
    such.

This pull request was prepared by an AI agent (Claude) on Siegfried's behalf.

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
{
return identifier.StartsWith("<>", StringComparison.Ordinal)
&& (identifier.Contains("TransparentIdentifier") || identifier.Contains("TranspIdent"));
return (identifier.StartsWith("<>", StringComparison.Ordinal)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use if + return for readability

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VB.NET anonymous types decompile to invalid C# (VB$AnonymousType_* use sites, hidden definitions)

1 participant