From 01895fbab752e5396ea7fca871a8bf2707922f9f Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 29 Jul 2026 08:22:54 +0200 Subject: [PATCH] Merge tuple element names during type-inference fixing When fixing a type parameter, Roslyn merges the tuple element names of bounds that are identical apart from those names: names are kept where all bounds agree and dropped where they conflict (MergeTupleNames in Roslyn's MethodTypeInference.cs). The C# standard does not describe this step. Without it, fixing either kept the first bound's names verbatim or, with two exact bounds differing only in names, failed outright - so inferred tuple types could carry names csc would not produce. All merged-name expectations are csc-verified. Nullability is deliberately not merged: Roslyn derives it from the variance of the position, which this implementation does not track, so bounds that differ in it stay distinct and fixing fails as before rather than inventing an annotation. Assisted-by: Claude:claude-fable-5:Claude Code Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../Semantics/TypeInferenceTests.cs | 87 ++++++++++- .../CSharp/Resolver/TypeInference.cs | 136 +++++++++++++++++- 2 files changed, 218 insertions(+), 5 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs index 900b7dcb9..2d0dfb4f9 100644 --- a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs +++ b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs @@ -798,7 +798,92 @@ namespace ICSharpCode.Decompiler.Tests.Semantics } [Test] - [Ignore("Not implemented: AddExactBound compares bounds with name-sensitive equality, so two exact bounds that differ only in tuple element names count as conflicting before any merging can happen; csc merges them (verified: M(ref T, ref T) with (int a, string b)/(int a, string c) compiles, T = (int a, string)).")] + public void FixingMergesTupleElementNamesAcrossLowerAndUpperBounds() + { + // Signature: M(T x, Action y) + // Invocation: M(listOfAB, actionOfListOfAC); -> T = IList<(int a, string)> + // Action is contravariant, so the second argument produces an upper bound + // while the first produces a lower bound. + var comp = tupleCompilation.Value; + var inference = new TypeInference(comp); + var T = new DefaultTypeParameter(comp, SymbolKind.Method, 0, "T"); + ITypeDefinition listType = comp.FindType(KnownTypeCode.IListOfT).GetDefinition(); + ITypeDefinition actionType = comp.FindType(typeof(Action<>)).GetDefinition(); + IType listOfAC = new ParameterizedType(listType, new[] { MakeTupleType(comp, "a", "c") }); + + bool success; + Assert.That( + inference.InferTypeArguments(new ITypeParameter[] { T }, + new[] { + new ResolveResult(new ParameterizedType(listType, new[] { MakeTupleType(comp, "a", "b") })), + new ResolveResult(new ParameterizedType(actionType, new[] { listOfAC })) + }, + new IType[] { + T, + new ParameterizedType(actionType, new IType[] { T }) + }, + out success), + Is.EqualTo(new[] { new ParameterizedType(listType, new[] { MakeTupleType(comp, "a", null) }) })); + Assert.That(success); + } + + [Test] + public void FixingMergesTupleElementNamesThroughEqualNullabilityAnnotations() + { + // Signature: M(T x, T y) + // Invocation: M(nullableListOfAB, nullableListOfAC); -> T = IList<(int a, string)>? + // M(nullableArrayOfAB, nullableArrayOfAC); -> T = (int a, string)[]? + var comp = tupleCompilation.Value; + ITypeDefinition listType = comp.FindType(KnownTypeCode.IListOfT).GetDefinition(); + + IType InferSingle(IType argType1, IType argType2) + { + var T = new DefaultTypeParameter(comp, SymbolKind.Method, 0, "T"); + var result = new TypeInference(comp).InferTypeArguments(new ITypeParameter[] { T }, + new[] { new ResolveResult(argType1), new ResolveResult(argType2) }, + new IType[] { T, T }, + out bool success); + Assert.That(success); + return result.Single(); + } + + IType NullableListOf(TupleType elementType) + => new ParameterizedType(listType, new[] { elementType }).ChangeNullability(Nullability.Nullable); + IType NullableArrayOf(TupleType elementType) + => new ArrayType(comp, elementType, 1, Nullability.Nullable); + + Assert.That( + InferSingle(NullableListOf(MakeTupleType(comp, "a", "b")), NullableListOf(MakeTupleType(comp, "a", "c"))), + Is.EqualTo(NullableListOf(MakeTupleType(comp, "a", null)))); + + Assert.That( + InferSingle(NullableArrayOf(MakeTupleType(comp, "a", "b")), NullableArrayOf(MakeTupleType(comp, "a", "c"))), + Is.EqualTo(NullableArrayOf(MakeTupleType(comp, "a", null)))); + } + + [Test] + public void FixingDoesNotMergeBoundsThatDifferInNullability() + { + // Signature: M(T x, T y) + // Invocation: M(nullableArrayOfString, arrayOfString); + // Merging nullability requires the variance of the position, which this + // implementation does not track, so such bounds stay distinct and fixing fails + // (csc infers string[]?). + var comp = tupleCompilation.Value; + var T = new DefaultTypeParameter(comp, SymbolKind.Method, 0, "T"); + IType stringType = comp.FindType(KnownTypeCode.String); + + new TypeInference(comp).InferTypeArguments(new ITypeParameter[] { T }, + new[] { + new ResolveResult(new ArrayType(comp, stringType, 1, Nullability.Nullable)), + new ResolveResult(new ArrayType(comp, stringType)) + }, + new IType[] { T, T }, + out bool success); + Assert.That(success, Is.False); + } + + [Test] public void FixingMergesTupleElementNamesOfMultipleExactBounds() { // Signature: M(ref T x, ref T y) diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs index e4d43cf4b..d579cfbdb 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.Linq; @@ -242,8 +243,18 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver // Exact bounds need to stored separately, not just as Lower+Upper bounds, // due to TypeInferenceTests.GenericArgumentImplicitlyConvertibleToAndFromAnotherTypeList (see #281) if (ExactBound == null) + { ExactBound = type; - else if (!ExactBound.Equals(type)) + return; + } + if (ExactBound.Equals(type)) + return; + // Two exact bounds that differ only in tuple element names are not conflicting; + // their names are merged instead (kept where both agree, dropped otherwise). + IType merged = MergeTupleNames(ExactBound, type); + if (merged != null) + ExactBound = merged; + else MultipleDifferentExactBounds = true; } @@ -968,8 +979,15 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver Debug.Assert(!tp.IsFixed); if (tp.ExactBound != null) { - // the exact bound will always be the result - tp.FixedTo = tp.ExactBound; + // Roslyn behavior (not in the C# standard): when a lower/upper bound has the + // same shape as the exact bound except for tuple element names, the names are + // merged - kept where both sides agree, dropped where they conflict. See + // MergeTupleNames in Roslyn's MethodTypeInference.cs. + IType fixedTo = tp.ExactBound; + foreach (var b in tp.LowerBounds.Concat(tp.UpperBounds)) + fixedTo = MergeTupleNames(fixedTo, b) ?? fixedTo; + // the exact bound determines the result, up to the merged element names + tp.FixedTo = fixedTo; // check validity if (tp.MultipleDifferentExactBounds) return false; @@ -977,7 +995,11 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver && tp.UpperBounds.All(b => conversions.ImplicitConversion(tp.FixedTo, b).IsValid); } Log.Indent(); - var types = CreateNestedInstance().FindTypesInBounds(tp.LowerBounds.ToArray(), tp.UpperBounds.ToArray()); + // Same Roslyn-style merge, over lower and upper bounds together as Roslyn's Fix does, + // so bounds that differ only in tuple element names don't survive into + // FindTypesInBounds as distinct candidates. + var (lowerBounds, upperBounds) = MergeShapeEquivalentBounds(tp.LowerBounds, tp.UpperBounds); + var types = CreateNestedInstance().FindTypesInBounds(lowerBounds, upperBounds); Log.Unindent(); if (algorithm == TypeInferenceAlgorithm.ImprovedReturnAllResults) { @@ -992,6 +1014,112 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver return types.Count == 1; } } + + /// + /// Merges the tuple element names of two types that are equal apart from those names: + /// a name is kept where both sides agree and dropped where they conflict. Returns + /// null if the types differ in anything else. + /// + static IType MergeTupleNames(IType a, IType b) + { + if (a.Equals(b)) + return a; + // Roslyn merges differing nullability based on the variance of the position; this + // implementation does not track that, so differently annotated types are left alone. + if (a.Nullability != b.Nullability) + return null; + if (a is NullabilityAnnotatedType na && b is NullabilityAnnotatedType nb) + { + return MergeTupleNames(na.TypeWithoutAnnotation, nb.TypeWithoutAnnotation) + ?.ChangeNullability(a.Nullability); + } + if (a is TupleType ta && b is TupleType tb + && ta.ElementTypes.Length == tb.ElementTypes.Length) + { + var mergedElements = ImmutableArray.CreateBuilder(ta.ElementTypes.Length); + for (int i = 0; i < ta.ElementTypes.Length; i++) + { + var merged = MergeTupleNames(ta.ElementTypes[i], tb.ElementTypes[i]); + if (merged == null) + return null; + mergedElements.Add(merged); + } + var mergedNames = ImmutableArray.CreateBuilder(ta.ElementNames.Length); + for (int i = 0; i < ta.ElementNames.Length; i++) + { + mergedNames.Add(ta.ElementNames[i] == tb.ElementNames[i] ? ta.ElementNames[i] : null); + } + return new TupleType(ta.Compilation, mergedElements.MoveToImmutable(), mergedNames.MoveToImmutable(), + ta.GetDefinition()?.ParentModule); + } + if (a is ParameterizedType pa && b is ParameterizedType pb + && pa.GenericType.Equals(pb.GenericType) + && pa.TypeArguments.Count == pb.TypeArguments.Count) + { + var mergedArgs = new IType[pa.TypeArguments.Count]; + for (int i = 0; i < pa.TypeArguments.Count; i++) + { + var merged = MergeTupleNames(pa.TypeArguments[i], pb.TypeArguments[i]); + if (merged == null) + return null; + mergedArgs[i] = merged; + } + return new ParameterizedType(pa.GenericType, mergedArgs); + } + if (a is ArrayType arrA && b is ArrayType arrB && arrA.Dimensions == arrB.Dimensions) + { + var mergedElem = MergeTupleNames(arrA.ElementType, arrB.ElementType); + if (mergedElem == null) + return null; + return new ArrayType(arrA.Compilation, mergedElem, arrA.Dimensions, arrA.Nullability); + } + return null; + } + + /// + /// Collapses bounds that are equal modulo tuple element names (possibly nested) into a + /// single merged type via , across both bound sets. Bounds + /// without a shape-equivalent partner are returned as-is. + /// + static (IReadOnlyList LowerBounds, IReadOnlyList UpperBounds) MergeShapeEquivalentBounds( + IReadOnlyCollection lowerBounds, IReadOnlyCollection upperBounds) + { + if (lowerBounds.Count + upperBounds.Count < 2) + return (lowerBounds.ToArray(), upperBounds.ToArray()); + var mergedBounds = new List(); + bool anyNamesMerged = false; + foreach (var bound in lowerBounds.Concat(upperBounds)) + { + bool absorbed = false; + for (int i = 0; i < mergedBounds.Count && !absorbed; i++) + { + IType merged = MergeTupleNames(mergedBounds[i], bound); + if (merged != null) + { + // Bounds that are already equal merge to the existing entry itself; + // only a new type means element names were actually merged. + anyNamesMerged |= !ReferenceEquals(merged, mergedBounds[i]); + mergedBounds[i] = merged; + absorbed = true; + } + } + if (!absorbed) + mergedBounds.Add(bound); + } + if (!anyNamesMerged) + return (lowerBounds.ToArray(), upperBounds.ToArray()); + return (MapToMergedBounds(lowerBounds, mergedBounds), MapToMergedBounds(upperBounds, mergedBounds)); + } + + /// + /// Replaces each bound with the entry of it was merged into. + /// Merging only changes element names, never the shape, so every bound is still + /// shape-equivalent to exactly one of those entries. + /// + static IType[] MapToMergedBounds(IEnumerable bounds, List mergedBounds) + { + return bounds.Select(b => mergedBounds.First(m => MergeTupleNames(m, b) != null)).Distinct().ToArray(); + } #endregion #region Finding the best common type of a set of expressions