Skip to content

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

Open
siegfriedpammer wants to merge 6 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 6 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.

Comment thread ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs Outdated
@siegfriedpammer
siegfriedpammer force-pushed the fix-3952-vb-anonymous-types branch 3 times, most recently from 98440f8 to 39725bb Compare August 7, 2026 07:10
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
siegfriedpammer force-pushed the fix-3952-vb-anonymous-types branch from 39725bb to 49c282a Compare August 7, 2026 07:11
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
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)

2 participants