Skip to content

Fix #3282: leave out trailing optional arguments in indexer accesses - #4043

Open
siegfriedpammer wants to merge 2 commits into
masterfrom
fix/3282-indexer-optional-arguments
Open

Fix #3282: leave out trailing optional arguments in indexer accesses#4043
siegfriedpammer wants to merge 2 commits into
masterfrom
fix/3282-indexer-optional-arguments

Conversation

@siegfriedpammer

Copy link
Copy Markdown
Member

Written by Claude (agent), posted on behalf of @siegfriedpammer.

Fixes #3282.

HandleAccessorCall had no way to express an omitted argument, so CallBuilder asserted that no optional argument had been detected before it got there. Any assembly that indexes through an indexer with an optional parameter hits that assert in a Debug build; Release builds silently wrote the defaults back out.

Let an indexer access leave out trailing optional arguments

Accessor calls now go through the same ArgumentList helpers as an ordinary call. Two things had to reach them: the assigned value of a setter is the last argument of the call but not an argument of the access - the standard adds it only for the invocation of the accessor (§12.6.2.1) - so it neither ends the run of optional arguments nor is written out with them; and the argument names have to stop wherever the argument list does.

Whether the shortened access still binds to the same member is left to IsUnambiguousAccess. If it does not, the omitted arguments are written out again before any cast is tried, since restoring them cannot change what the access means. A type declaring both this[int] and this[int, int = 10] therefore keeps both arguments; the fixture pins that.

Write named arguments for indexer accesses

C# allows named arguments in an element access, but NamedArgumentTransform refused to introduce one for any accessor, so an access whose arguments the compiler reordered came out as a temporary variable assigned before the access. Only indexers gain this: a property access has no argument list, an operator cannot take names either, and a setter's last argument stays unnamed on the right-hand side.

Tests

Indexer cases in OptionalArguments (get, set, compound assignment, increment, struct receiver, object initializer, and the overload that must keep its argument), in OptionalArgumentsDisabled (with the setting off the arguments stay explicit), and in NamedArguments.

Not covered

Still written out explicitly, each for its own reason: params indexers, [Optional] without a constant and [DateTimeConstant]-style defaults (nothing in the signature to compare against), default(T) at a value-type instantiation, and omitting a middle optional argument - the last of which plain calls do not do either (M(0, z: 9) decompiles to M(0, 1, 9)).

Interaction with #3972

Checked: all three commits of #3972 cherry-pick onto this branch without conflict, the combined tree builds, and its full decompiler suite is green (3467 tests, 0 failures), including c[1] += 5 on an indexer, which goes through both changes.

An indexer access is built by HandleAccessorCall, which had no way to express
an omitted argument, so CallBuilder asserted that no optional argument had been
detected before it got there. That assert fires on any assembly that indexes
through an indexer with an optional parameter (#3282), and Release builds
silently wrote the defaults back out.

The accessor call now goes through the same ArgumentList helpers as an ordinary
call, which decide how much of the argument list is written. Two things had to
reach them: the assigned value of a setter is the last argument of the call but
not an argument of the access - the standard adds it only for the invocation of
the accessor - so it neither ends the run of optional arguments nor is written
out with them; and the argument names have to stop wherever the argument list
does.

Whether the shortened access still binds to the same member is left to
IsUnambiguousAccess. If it does not, the omitted arguments are written out
again before any cast is tried, since restoring them cannot change what the
access means. A type declaring both this[int] and this[int, int = 10]
therefore keeps both arguments, which the fixture pins.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
C# allows named arguments in an element access, so an indexer access where the
compiler reordered the arguments can be recovered as written instead of as a
temporary variable assigned before the access. NamedArgumentTransform refused
to introduce a named argument for any accessor; CallBuilder writes them out
already, now that accessor calls share the argument-list handling of ordinary
calls.

Only indexers gain this: a property access has no argument list, and an
operator cannot take names either. A setter's last argument is the assigned
value, which is not part of the element access's argument list and corresponds
to the implicit value parameter, so it stays unnamed and is written as the
right-hand side.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
@siegfriedpammer
siegfriedpammer force-pushed the fix/3282-indexer-optional-arguments branch from 51d1808 to 34fc6db Compare August 23, 2026 15:54

@christophwille christophwille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review summary

The optional-argument part works for the fixtures added, but opening indexer accessors to the named-argument machinery (NamedArgumentTransform + HandleAccessorCall now consuming GetArgumentNames()/GetArgumentExpressions()) re-exposes several assumptions elsewhere that were only safe because accessor calls never carried names. All findings below were reproduced against a build of this branch with small probe assemblies; master decompiles the same probes correctly.

Default settings, plain Roslyn output (crash or uncompilable code):

  1. NamedArgumentTransform: an indexer setter inside a BlockKind.CallInlineAssign block gets replaced by a CallWithNamedArgs block -> Block.CheckInvariant assert (Debug) / MatchInlineAssignBlock() returned false (Release).
  2. HandleAccessorCall: when every indexer argument is an omitted trailing optional, zero arguments remain and the code falls into the property branches, emitting this.Item = 5 / initializedObject.Item.
  3. HandleAccessorCall: CastArguments pairs argument-order arguments with declaration-order method.Parameters; with reordered named indexer arguments each argument is cast to the wrong parameter's type.
  4. HandleAccessorCall: AddNamesToPrimitiveValues now applies to every indexer access (dictionary[true] -> dictionary[key: true]) and, unlike GetRequiredTransformationsForCall, the retry loop never tries turning it off before casting -> ((Base)d)[flag: true] on overridden indexers. Untested default-output change.

Need AggressiveInlining (or an aggressive context: catch-when, ctor initializer, expression tree):
5. CanExtendNamedArgument still names an indexer setter's value argument; CallWithNamedArgs then reorders it away from the last position, which BuildArgumentList/HandleAccessorCall rely on -> this[value: Get(0), y: Get(1)] = Get(2);.
6. An indexer getter that is the Target of a CompoundAssignmentInstruction can now become a CallWithNamedArgs block -> CompoundAssignmentInstruction.CheckValidTarget assert.

Low severity:
7. IsSetterAccessorWrittenAsAssignment and Build()'s routing condition can disagree after params expansion (contrived, not producible from C#).

Common root cause for 1, 5, 6: accessor calls can now be wrapped in CallWithNamedArgs blocks, but several IL consumers (CallInlineAssign invariant, CompoundAssignmentInstruction.CheckValidTarget, CanExtendNamedArgument) and the position-based "last argument is the value" logic in CallBuilder still assume they cannot. Guarding CanIntroduceNamedArgument against a call.Parent that is a CallInlineAssign block / compound-assignment target, plus identifying the setter value by parameter index rather than position, closes all three.

Minor cleanups (no separate comments): GetArgumentExpressions still does argumentNames.Take(argumentCount) after GetArgumentNames already truncates (dead); lastNameableArgument is an exclusive end (misnamed); the setter predicate is duplicated in three places (Build, IsSetterAccessorWrittenAsAssignment, NamedArgumentTransform).

Details and repro snippets are in the inline comments.

if (call.Method.IsAccessor)
{
// Only an indexer access has an argument list that can carry names.
if (call.Method.AccessorOwner is not IProperty { IsIndexer: true })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bug (default settings): indexer setter inside a CallInlineAssign block gets replaced by a CallWithNamedArgs block.

Check(this[y: Get(1), x: Get(2)] = Get(3));
// or
return this[y: Get(1), x: Get(2)] = this[y: Get(3), x: Get(4)];

TransformAssignment builds Block CallInlineAssign { call set_Item(this, ldloc t2, ldloc t1, stloc i(Get(3))); final: ldloc i }. Inlining t1 stops at Get(2), CanIntroduceNamedArgument (indexer setter, index 1 != 3, load at index 2) returns NamedArgument, NonAggressiveInlineInto passes (t1 was stored with this on the IL stack), and IntroduceNamedArgument does call.ReplaceWith(namedArgBlock) inside the CallInlineAssign block, breaking MatchInlineAssignBlock. Reproduced: Debug build terminates with Assertion failed (Block.cs:120); Release emits Error: MatchInlineAssignBlock() returned false. Master emits int y = Get(1); int i = (this[Get(2), y] = Get(3));.

Same root cause, needs AggressiveInlining (or any aggressive context like a catch-when filter): this[y: Get(1), x: Get(2)] += 5; -- the indexer getter is the Target of a CompoundAssignmentInstruction, which CheckValidTarget requires to be a Call/CallVirt; it now becomes a CallWithNamedArgs block -> assert at CompoundAssignmentInstruction.cs:97. The non-aggressive default is only saved by the ILStackWasEmpty heuristic.

Suggest returning FindResult.Stop here when call.Parent is a CallInlineAssign block or a CompoundAssignmentInstruction target (i.e. only allow it where IntroduceNamedArgument may legally replace the call by a block).

if (call.Method.Parameters.Any(p => string.IsNullOrEmpty(p.Name)))
return FindResult.Stop; // cannot use named arguments
for (int i = child.ChildIndex; i < call.Arguments.Count; i++)
int lastNameableArgument = isIndexerSetter ? call.Arguments.Count - 1 : call.Arguments.Count;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bug (AggressiveInlining): CanExtendNamedArgument (unchanged, below) has no equivalent value guard. It still hands out any ldloc v call argument as a named argument, including an indexer setter's value. CallWithNamedArgs then reorders the arguments so the value is no longer last, but BuildArgumentList (isSetter && i + 1 == callArguments.Count) and HandleAccessorCall (Arguments[Length - 1]) identify the value by position.

int s = Get(0);
this[y: Get(1), x: Get(2)] = s;

After the index temp is named, inlining s hits FindLoadInNext(Block CallWithNamedArgs) -> CanExtendNamedArgument -> the foreach loop returns NamedArgument for the value. PR build emits this[value: Get(0), y: Get(1)] = Get(2); (wrong and uncompilable). Non-aggressive mode is only saved by NonAggressiveInlineInto's ILStackWasEmpty heuristic.

Fix at the root: identify the setter value by parameter index (argumentToParameterMap[i] == Parameters.Count - 1) in CallBuilder, and/or add the same guard to CanExtendNamedArgument.

}
}

var arguments = argumentList.GetArgumentExpressions().ToList();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bug (default settings): an all-optional indexer yields zero arguments and falls into the property branches.

public int this[int x = 10] { get; set; }
...
this[10] = 5; this[10] += 5; Console.WriteLine(this[10]); new Probe4 { [10] = 3 };

decompiles (PR build, default settings) to this.Item = 5; this.Item += 5; Console.WriteLine(this.Item); new Probe4 { (initializedObject.Item = 3) } -- none of which compiles. When every argument is an omitted trailing optional, GetArgumentExpressions() is empty and the arguments.Count == 0 branches below emit a property access. The all-optional case must keep at least one argument (or FirstOptionalArgumentIndex must never reach 0 for indexers). OptionalArguments.cs only covers this[int x, int y = 10]; please add an all-optional fixture.

CastArguments(arguments, method.Parameters.ToList());
CastArguments(
new ArraySegment<TranslatedExpression>(argumentList.Arguments, 0, argumentList.GetActualArgumentCount()),
method.Parameters.ToList());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bug (default settings): the arguments are in argument order, method.Parameters in declaration order. With reordered named indexer arguments (now possible since CheckNoNamedOrOptionalArguments was dropped for indexers) each argument is cast to the wrong parameter's type.

// this[int i, object o] and this[int i, string o]
var v = this[o: (Get(1) == 1) ? "a" : (object)"b", i: Get(2)];

Overload resolution is ambiguous once the (object) cast is dropped, the loop casts, and the PR build emits this[o: (int)((Get(1) == 1) ? "a" : "b"), i: (object)Get(2)] (setter form too) -- uncompilable. Pass argumentList.ExpectedParameters (argument order) instead, as GetRequiredTransformationsForCall does via paramTypesInArgumentOrder (~line 1445).

IMember? foundMember;
while (!IsUnambiguousAccess(expectedTargetDetails, targetResolveResult, method, arguments, argumentNames, out foundMember))
while (!IsUnambiguousAccess(expectedTargetDetails, targetResolveResult, method,
argumentList.GetArgumentResolveResults(), argumentList.GetArgumentNames(), out foundMember))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Default-output change, untested: AddNamesToPrimitiveValues now applies to every indexer access, and the retry loop never turns it off. GetRequiredTransformationsForCall tries AddNamesToPrimitiveValues = false before casting arguments/target; this loop does not, so names win over casts.

PR build, default settings: dictionary[true] -> dictionary[key: true], this[true] -> this[flag: true] (master: dictionary[true]). Worse, with class Base { virtual this[bool flag] }, class Derived : Base { override this[bool f] } and d[true] on a Derived d (IL: callvirt Base::get_Item on a Derived receiver), the names ['flag'] fail against the override's parameter name and the loop ends in a target cast: ((Base)d)[flag: true] where master emitted d[true].

Either don't apply primitive-value names to accessor calls, or add the AddNamesToPrimitiveValues = false step first in this loop -- and add a bool-indexer fixture either way (the PR description does not mention this change).

if (!method.IsAccessor || !method.ReturnType.IsKnownType(KnownTypeCode.Void))
return false;
// Same shapes that Build() hands to HandleAccessorCall.
return method.AccessorOwner!.SymbolKind == SymbolKind.Indexer || method.Parameters.Count == 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low severity / contrived: this predicate (method.Parameters.Count == 1) and Build()'s routing condition (argumentList.ExpectedParameters.Length == allowedParamCount) can disagree after params expansion. An IL-level void accessor set_Foo([ParamArray] int[] value) with ExpandParamsArguments is expanded to N arguments by TransformParamsArgument, Build skips HandleAccessorCall, but IsSetter is already true, so GetArgumentExpressions() drops the last argument in the ordinary invocation path: set_Foo(1) for set_Foo(1, 2). Not producible from C#, but the comment "Same shapes that Build() hands to HandleAccessorCall" is not literally true.

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.

Indexer invocations should allow optional arguments

2 participants