From a926ae7e8b5d71f836ee49511236d0eeaec063da Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 8 Jul 2026 18:30:33 +0200 Subject: [PATCH] Show a hover for the indexer of a dynamic index access A dynamic index access (a[b]) gave its IndexerExpression a DynamicInvocationResolveResult with no symbol, so the brackets carried no tooltip. Synthesize an indexer (FakeProperty, IsIndexer) on the target type with the index parameters typed from the callsite delegate, and attach it. Route it hover-only by detecting a DynamicInvocationResolveResult directly on the node - which also covers an invoke-member's own parentheses, so those stop producing a dead navigation link too. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Output/CSharpAmbienceTests.cs | 19 +++++++++++++ .../CSharp/ExpressionBuilder.cs | 24 +++++++++++++++-- .../Output/TextTokenWriter.cs | 4 +++ ILSpy.Tests/Editor/HoverOnlyReferenceTests.cs | 27 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/Output/CSharpAmbienceTests.cs b/ICSharpCode.Decompiler.Tests/Output/CSharpAmbienceTests.cs index 08632d998..b81065949 100644 --- a/ICSharpCode.Decompiler.Tests/Output/CSharpAmbienceTests.cs +++ b/ICSharpCode.Decompiler.Tests/Output/CSharpAmbienceTests.cs @@ -355,6 +355,25 @@ namespace ICSharpCode.Decompiler.Tests.Output ambience.ConversionFlags = ConversionFlags.All & ~(ConversionFlags.ShowBody | ConversionFlags.PlaceReturnTypeAfterParameterList); Assert.That(ambience.ConvertSymbol(method), Is.EqualTo("public dynamic dynamic.Compute(int, string, dynamic)")); } + [Test] + public void DynamicIndexer() + { + // The shape ExpressionBuilder synthesizes for a[b]: an indexer with dynamic return, index + // parameters typed from the callsite delegate, declared on the dynamic type. It carries no + // accessors, which the ambience renders cleanly (no empty { } artifact). + var indexer = new FakeProperty(compilation) { + Name = "Item", + IsIndexer = true, + ReturnType = SpecialType.Dynamic, + DeclaringType = SpecialType.Dynamic, + Parameters = new IParameter[] { new DefaultParameter(compilation.FindType(KnownTypeCode.Int32), string.Empty) }, + }; + ambience.ConversionFlags = ConversionFlags.ShowReturnType | ConversionFlags.ShowParameterList; + Assert.That(ambience.ConvertSymbol(indexer), Is.EqualTo("dynamic this[int]")); + + ambience.ConversionFlags = ConversionFlags.All & ~(ConversionFlags.ShowBody | ConversionFlags.PlaceReturnTypeAfterParameterList); + Assert.That(ambience.ConvertSymbol(indexer), Is.EqualTo("public dynamic dynamic.this[int]")); + } #endregion #region Test types diff --git a/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs b/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs index 1e364d5da..f2504ac9c 100644 --- a/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs @@ -4348,7 +4348,8 @@ namespace ICSharpCode.Decompiler.CSharp var arguments = TranslateDynamicArguments(inst.Arguments.Skip(1), inst.ArgumentInfo.Skip(1)).ToList(); return new IndexerExpression(target, arguments.Select(a => a.Expression)) .WithILInstruction(inst) - .WithRR(new DynamicInvocationResolveResult(target.ResolveResult, DynamicInvocationType.Indexing, arguments.Select(a => a.ResolveResult).ToArray())); + .WithRR(new DynamicInvocationResolveResult(target.ResolveResult, DynamicInvocationType.Indexing, arguments.Select(a => a.ResolveResult).ToArray(), + symbol: CreateDynamicIndexerSymbol(DynamicArgumentType(inst.ArgumentInfo[0]), inst.ArgumentInfo.Skip(1).ToArray()))); } protected internal override TranslatedExpression VisitDynamicGetMemberInstruction(DynamicGetMemberInstruction inst, TranslationContext context) @@ -4509,6 +4510,24 @@ namespace ICSharpCode.Decompiler.CSharp return constructor; } + /// + /// Synthesizes the indexer for a dynamic index access (a[b]): an indexer on the target type whose + /// parameters are typed from the callsite delegate, so the brackets carry a hover tooltip. + /// + IMember CreateDynamicIndexerSymbol(IType declaringType, IReadOnlyList argumentInfo) + { + var parameters = new IParameter[argumentInfo.Count]; + for (int i = 0; i < argumentInfo.Count; i++) + parameters[i] = new DefaultParameter(DynamicArgumentType(argumentInfo[i]), argumentInfo[i].Name ?? string.Empty); + return new FakeProperty(compilation) { + Name = "Item", + IsIndexer = true, + ReturnType = SpecialType.Dynamic, + DeclaringType = declaringType, + Parameters = parameters, + }; + } + IEnumerable TranslateDynamicArguments(IEnumerable arguments, IEnumerable argumentInfo) { foreach (var (argument, info) in arguments.Zip(argumentInfo)) @@ -4575,7 +4594,8 @@ namespace ICSharpCode.Decompiler.CSharp var value = new TranslatedExpression(arguments.Last()); var indexer = new IndexerExpression(target, arguments.SkipLast(1).Select(a => a.Expression)) .WithoutILInstruction() - .WithRR(new DynamicInvocationResolveResult(target.ResolveResult, DynamicInvocationType.Indexing, arguments.SkipLast(1).Select(a => a.ResolveResult).ToArray())); + .WithRR(new DynamicInvocationResolveResult(target.ResolveResult, DynamicInvocationType.Indexing, arguments.SkipLast(1).Select(a => a.ResolveResult).ToArray(), + symbol: CreateDynamicIndexerSymbol(DynamicArgumentType(inst.ArgumentInfo[0]), inst.ArgumentInfo.Skip(1).Take(inst.ArgumentInfo.Count - 2).ToArray()))); return Assignment(indexer, value).WithILInstruction(inst); } diff --git a/ICSharpCode.Decompiler/Output/TextTokenWriter.cs b/ICSharpCode.Decompiler/Output/TextTokenWriter.cs index 2ea45c712..e9d796078 100644 --- a/ICSharpCode.Decompiler/Output/TextTokenWriter.cs +++ b/ICSharpCode.Decompiler/Output/TextTokenWriter.cs @@ -159,6 +159,10 @@ namespace ICSharpCode.Decompiler { if (node.Annotation() is CSharp.Resolver.DynamicMemberResolveResult) return true; + // The node itself is a dynamic invocation/indexing (a.Method(b), a[b]): its parentheses/brackets + // carry the synthesized member. + if (node.Annotation() is CSharp.Resolver.DynamicInvocationResolveResult) + return true; if (node.Slot?.Kind == Slots.TargetExpression && node.Parent is InvocationExpression && node.Parent.Annotation() is CSharp.Resolver.DynamicInvocationResolveResult) return true; diff --git a/ILSpy.Tests/Editor/HoverOnlyReferenceTests.cs b/ILSpy.Tests/Editor/HoverOnlyReferenceTests.cs index 756342be5..2f7fc75a8 100644 --- a/ILSpy.Tests/Editor/HoverOnlyReferenceTests.cs +++ b/ILSpy.Tests/Editor/HoverOnlyReferenceTests.cs @@ -49,6 +49,11 @@ public class DynamicMemberSample { return d.Compute(1, "two", d); } + + public object Index(dynamic d) + { + return d[0]; + } } /// @@ -111,4 +116,26 @@ public class HoverOnlyReferenceTests "each argument is typed from the callsite: the constants keep their compile-time type, " + "the dynamic argument stays dynamic"); } + + [AvaloniaTest] + public async Task Dynamic_Index_Access_Is_A_HoverOnly_Indexer() + { + var (_, vm) = await TestHarness.BootAsync(); + await vm.OpenAssemblyAsync(typeof(DynamicMemberSample).Assembly.Location); + var typeNode = vm.AssemblyTreeModel.FindNode( + "ILSpy.Tests", + "ICSharpCode.ILSpy.Tests.TextView", + "ICSharpCode.ILSpy.Tests.TextView.DynamicMemberSample"); + vm.AssemblyTreeModel.SelectNode(typeNode); + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + // The brackets of d[0] carry a synthesized indexer. + var bracket = tab.References!.First(r => r.Reference is IProperty { IsIndexer: true }); + bracket.Kind.Should().Be(ReferenceMode.HoverOnly, "the synthesized indexer has no metadata to jump to"); + + var ambience = new CSharpAmbience { + ConversionFlags = ConversionFlags.ShowReturnType | ConversionFlags.ShowParameterList, + }; + ambience.ConvertSymbol((IProperty)bracket.Reference!).Should().Be("dynamic this[int]"); + } }