Skip to content

Decide extension method syntax in CallBuilder - #4160

Open
siegfriedpammer wants to merge 4 commits into
masterfrom
refactor/extension-method-syntax-in-callbuilder
Open

siegfriedpammer wants to merge 4 commits into
masterfrom
refactor/extension-method-syntax-in-callbuilder

Conversation

@siegfriedpammer

Copy link
Copy Markdown
Member

Summary:

  • Resolve extension method call syntax in CallBuilder and remove the separate IntroduceExtensionMethods transform.
  • Add Pretty fixture coverage for extension method syntax.

Tests:

  • Not run (history reorder and PR creation only).

Assisted-by: OpenCode:gpt-5.5:opencode

C# resolves an extension method one namespace at a time, starting at the
namespace the call is written in and working outwards through the
enclosing ones before it considers any import. A library that defines
GetCustomAttributes(this MemberInfo) next to its callers therefore beats
System.Reflection's identically shaped one, and the call compiles.

ExpressionBuilder's resolver had a flat scope: the namespace superset was
one undifferentiated list of imports, so the two competed as equals and
the call looked ambiguous. The deleted AST transform did not have this
problem because it nested its scope into the current type's namespace
before resolving anything.

The scope is memoized per namespace on the DecompileRun, because the set
of extension methods a scope can reach is computed once per scope and an
ExpressionBuilder exists per function.

Assisted-by: Claude:claude-opus-5:Claude Code
An extension method reference binds its receiver as the target, so the
delegate it is converted to has one parameter fewer than the method.
PerformOverloadResolution prepends the target itself when extension
methods are allowed, so handing it the receiver's parameter as well left
it with one argument too many: every candidate came back
TooManyPositionalArguments, the reference was reported ambiguous, and the
escalation ran to the end and wrote out type arguments that inference
could have supplied.

Two fixtures pinned that output. Both spellings compile to byte-identical
IL - Use2(ints.Select) binds Enumerable.Select<int32, int32> and the same
Use2 overload as Use2(ints.Select<int, int>), and .Any is the same
Enumerable.Any<int32> as .Any<int> inside the expression tree - so the
shorter one is what the escalation is supposed to arrive at.

Assisted-by: Claude:claude-opus-5:Claude Code
Extension call syntax had no fixture of its own; it was covered only
incidentally by QualifierTests, Issue1080, RefLocalsAndReturns and a few
others. The cases that decide how the call is spelled - null receiver,
ref/in receiver, a named argument after the receiver, type arguments
inference cannot reach, params expansion, and an instance method that
beats the extension so the call has to stay static - now have one place
that fails if any of them moves.

Assisted-by: Claude:claude-opus-5:Claude Code
Every other decision about how a member reference is spelled is made
while the expression is built, behind Disambiguator, and CallBuilder
already writes target.ExtensionMethod for delegate references and method
groups. Extension calls were the last spelling decided afterwards, by an
AST transform that had to rebuild a resolver from a UsingScope
annotation on the tree root and re-derive every argument's resolve
result from AST annotations.

The check now runs against the resolver ExpressionBuilder already owns,
whose using scope is the namespace superset the DecompileRun collected
from the IL. That superset is wider than the imports actually printed,
so it can only refuse infix syntax where the transform allowed it, never
bind a call to a different overload.

Two consequences had to be handled. The declaring type is no longer
named anywhere once the call is infix, so IntroduceUsingDeclarations
would not have imported its namespace and the output would not compile;
it now imports from the invocation itself. And an extension GetEnumerator
in infix form is spelled like an instance call, so the foreach matcher
can no longer read a plain match as an instance method.

Writing the receiver as the target means skipping an argument, which the
argument list only supported where there were neither names nor optional
arguments: it skipped before trimming the optional tail, so a skip past
an omitted argument took one argument too many, and it gave up entirely
on names. Both now skip after trimming, which also settles a latent
off-by-one on the collection-initializer path.

Assisted-by: Claude:claude-opus-5:Claude Code
@siegfriedpammer
siegfriedpammer force-pushed the refactor/extension-method-syntax-in-callbuilder branch from 3fe0234 to 9b16bf3 Compare September 21, 2026 14:59

This branch has not been deployed

No deployments
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.

1 participant