From 354dc79255e76925f63629000c3a6a1365cc9326 Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sat, 29 Aug 2026 13:09:26 +0200 Subject: [PATCH 1/8] Move MergeSimilarTypes into FindTypesInBounds. This way, we don't need the MapToMergedBounds logic to split the merged list back into lower/upper. Also, this commit avoids the quadratic merge-everything-with-everything else -- instead we use a dictionary to compare only types that are equivalent to begin with. This is the same approach as Roslyn MethodTypeInference.Fix/AddAllCandidates. --- .../Semantics/TypeInferenceTests.cs | 12 +++ .../CSharp/Resolver/TypeInference.cs | 92 ++++++++----------- .../Implementation/DecoratedType.cs | 5 + .../NullabilityAnnotatedType.cs | 5 + .../TypeSystem/NormalizeTypeVisitor.cs | 28 ++++++ 5 files changed, 87 insertions(+), 55 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs index 0b40a02a1..0770355eb 100644 --- a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs +++ b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs @@ -1245,6 +1245,18 @@ namespace ICSharpCode.Decompiler.Tests.Semantics Is.EqualTo(SpecialType.Dynamic)); Assert.That(success); } + + [Test] + public void BestCommonTypeDynamicAndObject() + { + Assert.That( + ti.GetBestCommonType(new[] { + new ResolveResult(SpecialType.Dynamic), + new ResolveResult(compilation.FindType(KnownTypeCode.Object)) + }, out bool success), + Is.EqualTo(SpecialType.Dynamic)); + Assert.That(success); + } #endregion #region FindTypeInBounds diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs index 62e80ddc8..41aa50324 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs @@ -1000,9 +1000,7 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver // 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(); + var types = CreateNestedInstance().FindTypesInBounds(tp.LowerBounds, tp.UpperBounds); if (algorithm == TypeInferenceAlgorithm.ImprovedReturnAllResults) { tp.FixedTo = IntersectionType.Create(types); @@ -1086,51 +1084,6 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver } 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 = MergeSimilarTypes(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 => MergeSimilarTypes(m, b) != null)).Distinct().ToArray(); - } #endregion #region Finding the best common type of a set of expressions @@ -1169,7 +1122,7 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver /// /// Finds a type that satisfies the given lower and upper bounds. /// - public IType FindTypeInBounds(IReadOnlyList lowerBounds, IReadOnlyList upperBounds) + public IType FindTypeInBounds(IReadOnlyCollection lowerBounds, IReadOnlyCollection upperBounds) { if (lowerBounds == null) throw new ArgumentNullException(nameof(lowerBounds)); @@ -1189,13 +1142,13 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver } } - static IType GetFirstTypePreferNonInterfaces(IReadOnlyList result) + static IType GetFirstTypePreferNonInterfaces(IReadOnlyCollection result) { return result.FirstOrDefault(c => c.Kind != TypeKind.Interface) ?? result.FirstOrDefault() ?? SpecialType.UnknownType; } - IReadOnlyList FindTypesInBounds(IReadOnlyList lowerBounds, IReadOnlyList upperBounds) + IReadOnlyCollection FindTypesInBounds(IReadOnlyCollection lowerBounds, IReadOnlyCollection upperBounds) { // If there's only a single type; return that single type. // If both inputs are empty, return the empty list. @@ -1210,8 +1163,36 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver Log.WriteCollection("FindTypesInBound, LowerBounds=", lowerBounds); Log.WriteCollection("FindTypesInBound, UpperBounds=", upperBounds); + // Deduplicate types. This also merges types that differ only in tuple + // element names and/or object/dynamic. + var candidateMergeDict = new Dictionary(); + foreach (var candidate in lowerBounds.Concat(upperBounds)) + { + var key = candidate.AcceptVisitor(NormalizeTypeVisitor.KeyForTypeMerging); + if (candidateMergeDict.TryGetValue(key, out var existing)) + { + var merged = MergeSimilarTypes(existing, candidate); + Log.WriteLine(" Merged similar types " + existing + " and " + candidate + " into " + merged); + if (merged != null) + { + candidateMergeDict[key] = merged; + } + else + { + Debug.Fail("MergeSimilarTypes should always be able to merge;" + + " is the KeyForTypeMerging visitor misconfigured?"); + } + } + else + { + candidateMergeDict.Add(key, candidate); + } + } + + Log.WriteCollection("FindTypesInBound, Merged types from bounds=", candidateMergeDict.Values); + // First try the Fixing algorithm from the C# spec (ยง12.6.3.13) - List candidateTypes = lowerBounds.Union(upperBounds) + List candidateTypes = candidateMergeDict.Values .Where(c => lowerBounds.All(b => conversions.ImplicitConversion(b, c).IsValid)) .Where(c => upperBounds.All(b => conversions.ImplicitConversion(c, b).IsValid)) .ToList(); // evaluate the query only once @@ -1240,10 +1221,11 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver if (lowerBounds.Count > 0) { // Find candidates by using the lower bounds: - var hashSet = new HashSet(lowerBounds[0].GetAllBaseTypeDefinitions()); - for (int i = 1; i < lowerBounds.Count; i++) + var lowerBoundsList = lowerBounds.ToList(); + var hashSet = new HashSet(lowerBoundsList[0].GetAllBaseTypeDefinitions()); + for (int i = 1; i < lowerBoundsList.Count; i++) { - hashSet.IntersectWith(lowerBounds[i].GetAllBaseTypeDefinitions()); + hashSet.IntersectWith(lowerBoundsList[i].GetAllBaseTypeDefinitions()); } candidateTypeDefinitions = hashSet.ToList(); } diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/DecoratedType.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/DecoratedType.cs index e5f1c4996..5b00d4e99 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/DecoratedType.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/DecoratedType.cs @@ -60,7 +60,12 @@ namespace ICSharpCode.Decompiler.TypeSystem.Implementation public abstract IType AcceptVisitor(TypeVisitor visitor); + public abstract override int GetHashCode(); public abstract bool Equals(IType other); + public sealed override bool Equals(object obj) + { + return Equals(obj as IType); + } IEnumerable IType.GetAccessors(Predicate filter, GetMemberOptions options) { diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/NullabilityAnnotatedType.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/NullabilityAnnotatedType.cs index 1b94f2e39..cdef4349a 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/NullabilityAnnotatedType.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/NullabilityAnnotatedType.cs @@ -52,6 +52,11 @@ namespace ICSharpCode.Decompiler.TypeSystem.Implementation return visitor.VisitNullabilityAnnotatedType(this); } + public override int GetHashCode() + { + return baseType.GetHashCode() ^ nullability.GetHashCode(); + } + public override bool Equals(IType other) { return other is NullabilityAnnotatedType nat diff --git a/ICSharpCode.Decompiler/TypeSystem/NormalizeTypeVisitor.cs b/ICSharpCode.Decompiler/TypeSystem/NormalizeTypeVisitor.cs index a1eafe028..14c4fe91a 100644 --- a/ICSharpCode.Decompiler/TypeSystem/NormalizeTypeVisitor.cs +++ b/ICSharpCode.Decompiler/TypeSystem/NormalizeTypeVisitor.cs @@ -18,7 +18,10 @@ #nullable enable +using System.Linq; + using ICSharpCode.Decompiler.TypeSystem.Implementation; +using ICSharpCode.Decompiler.Util; namespace ICSharpCode.Decompiler.TypeSystem { @@ -34,6 +37,7 @@ namespace ICSharpCode.Decompiler.TypeSystem DynamicAndObject = true, IntPtrToNInt = true, TupleToUnderlyingType = true, + RemoveTupleElementNames = false, RemoveModOpt = true, RemoveModReq = true, RemoveNullability = true, @@ -45,17 +49,32 @@ namespace ICSharpCode.Decompiler.TypeSystem DynamicAndObject = false, IntPtrToNInt = false, TupleToUnderlyingType = true, + RemoveTupleElementNames = false, RemoveModOpt = true, RemoveModReq = true, RemoveNullability = true, }; + // Used in type inference to group together similar types that can be merged. + internal static readonly NormalizeTypeVisitor KeyForTypeMerging = new NormalizeTypeVisitor { + ReplaceClassTypeParametersWithDummy = false, + ReplaceMethodTypeParametersWithDummy = false, + DynamicAndObject = true, + IntPtrToNInt = false, + TupleToUnderlyingType = false, + RemoveTupleElementNames = true, + RemoveModOpt = false, + RemoveModReq = false, + RemoveNullability = false, + }; + internal static readonly NormalizeTypeVisitor IgnoreNullability = new NormalizeTypeVisitor { ReplaceClassTypeParametersWithDummy = false, ReplaceMethodTypeParametersWithDummy = false, DynamicAndObject = false, IntPtrToNInt = false, TupleToUnderlyingType = false, + RemoveTupleElementNames = false, RemoveModOpt = true, RemoveModReq = true, RemoveNullability = true, @@ -75,6 +94,7 @@ namespace ICSharpCode.Decompiler.TypeSystem public bool DynamicAndObject = true; public bool IntPtrToNInt = true; public bool TupleToUnderlyingType = true; + public bool RemoveTupleElementNames = true; public bool RemoveNullability = true; public override IType VisitTypeParameter(ITypeParameter type) @@ -129,6 +149,14 @@ namespace ICSharpCode.Decompiler.TypeSystem { return type.UnderlyingType.AcceptVisitor(this); } + else if (RemoveTupleElementNames && type.ElementNames.Any(name => name != null)) + { + return new TupleType( + type.Compilation, + type.ElementTypes.SelectImmutableArray(t => t.AcceptVisitor(this)), + default, + type.GetDefinition()?.ParentModule); + } else { return base.VisitTupleType(type); From 979f5c9f02fa74515d1dd2e1f055c2bf45d0e457 Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sat, 29 Aug 2026 21:10:29 +0200 Subject: [PATCH 2/8] Add support for merging types with different nullability. --- .../Semantics/TypeInferenceTests.cs | 35 +++-- .../CSharp/Resolver/TypeInference.cs | 128 +++++++++++++----- .../TypeSystem/ITypeParameter.cs | 21 ++- .../TypeSystem/NormalizeTypeVisitor.cs | 2 +- 4 files changed, 138 insertions(+), 48 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs index 0770355eb..e70ab6579 100644 --- a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs +++ b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs @@ -854,25 +854,24 @@ namespace ICSharpCode.Decompiler.Tests.Semantics } [Test] - public void FixingDoesNotMergeBoundsThatDifferInNullability() + public void FixingMergesBoundsThatDifferInNullability() { // 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[]?). + // Merging nullability in this covariant position should result in T=string[]? (the nullable array type). var comp = RefAssemblyCompilation.Instance; var T = new DefaultTypeParameter(comp, SymbolKind.Method, 0, "T"); IType stringType = comp.FindType(KnownTypeCode.String); - new TypeInference(comp).InferTypeArguments(new ITypeParameter[] { T }, - new[] { + var result = new TypeInference(comp).InferTypeArguments([T], + [ new ResolveResult(new ArrayType(comp, stringType, 1, Nullability.Nullable)), new ResolveResult(new ArrayType(comp, stringType)) - }, - new IType[] { T, T }, + ], + [T, T], out bool success); - Assert.That(success, Is.False); + Assert.That(success, Is.True); + Assert.That(result, Is.EqualTo([new ArrayType(comp, stringType, 1, Nullability.Nullable)])); } [Test] @@ -1257,6 +1256,18 @@ namespace ICSharpCode.Decompiler.Tests.Semantics Is.EqualTo(SpecialType.Dynamic)); Assert.That(success); } + + [Test] + public void BestCommonTypeObjectAndNullableObject() + { + Assert.That( + ti.GetBestCommonType(new[] { + new ResolveResult(compilation.FindType(KnownTypeCode.Object).ChangeNullability(Nullability.NotNullable)), + new ResolveResult(compilation.FindType(KnownTypeCode.Object).ChangeNullability(Nullability.Nullable)) + }, out bool success), + Is.EqualTo(compilation.FindType(KnownTypeCode.Object).ChangeNullability(Nullability.Nullable))); + Assert.That(success); + } #endregion #region FindTypeInBounds @@ -1399,8 +1410,12 @@ namespace ICSharpCode.Decompiler.Tests.Semantics // ReadOnlyCollectionBuilder appears because the test compilation includes // System.Core, which declares it as another public implementation of both // IList and IList. + var typesInBounds = FindAllTypesInBounds(Resolve(), Resolve(typeof(IEnumerable), typeof(IEnumerable), typeof(IList))); + // As this finds all derived types, the result set contains compiler-generated types like <>z__ReadOnlyArray`1. + // We filter those out to make the test more robust against changes. + typesInBounds = typesInBounds.Where(t => !t.GetDefinition().IsCompilerGenerated()).ToArray(); Assert.That( - FindAllTypesInBounds(Resolve(), Resolve(typeof(IEnumerable), typeof(IEnumerable), typeof(IList))), + typesInBounds, Is.EqualTo(Resolve(typeof(List), typeof(List), typeof(Collection), typeof(Collection), typeof(ReadOnlyCollection), typeof(ReadOnlyCollection), typeof(System.Runtime.CompilerServices.ReadOnlyCollectionBuilder), typeof(System.Runtime.CompilerServices.ReadOnlyCollectionBuilder)))); } #endregion diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs index 41aa50324..959982ec9 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs @@ -251,7 +251,7 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver 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 = MergeSimilarTypes(ExactBound, type); + IType merged = MergeSimilarTypes(ExactBound, type, VarianceModifier.Invariant); if (merged != null) ExactBound = merged; else @@ -986,8 +986,14 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver // 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 = MergeSimilarTypes(fixedTo, b) ?? fixedTo; + foreach (var b in tp.LowerBounds) + { + fixedTo = MergeSimilarTypes(fixedTo, b, VarianceModifier.Covariant) ?? fixedTo; + } + foreach (var b in tp.UpperBounds) + { + fixedTo = MergeSimilarTypes(fixedTo, b, VarianceModifier.Contravariant) ?? fixedTo; + } // the exact bound determines the result, up to the merged element names tp.FixedTo = fixedTo; // check validity @@ -1016,23 +1022,23 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver } /// - /// Merges similar types that differ only in tuple element names and/or object/dynamic, recursively. - /// * for tuple element names, a name is kept where both sides agree and dropped where they conflict. - /// * for object/dynamic, dynamic is preferred over object. + /// Merges similar types that differ only in any of these aspects. + /// * tuple element names: a name is kept where both sides agree and dropped where they conflict. + /// * object/dynamic: dynamic is preferred over object. + /// * nullability: depends on the variance of the position. /// Returns null if the types differ in any other aspects. /// - static IType MergeSimilarTypes(IType a, IType b) + static IType MergeSimilarTypes(IType a, IType b, VarianceModifier variance) { 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) + if (a is NullabilityAnnotatedType || b is NullabilityAnnotatedType) { - return MergeSimilarTypes(na.TypeWithoutAnnotation, nb.TypeWithoutAnnotation) - ?.ChangeNullability(a.Nullability); + var nullability = MergeNullability(a.Nullability, b.Nullability, variance); + var merged = MergeSimilarTypes(a.WithoutNullability(), b.WithoutNullability(), variance); + if (merged == null) + return null; + return merged.ChangeNullability(nullability); } if (a.Kind == TypeKind.Dynamic && b.IsKnownType(KnownTypeCode.Object)) { @@ -1048,7 +1054,11 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver var mergedElements = ImmutableArray.CreateBuilder(ta.ElementTypes.Length); for (int i = 0; i < ta.ElementTypes.Length; i++) { - var merged = MergeSimilarTypes(ta.ElementTypes[i], tb.ElementTypes[i]); + // Note: even though ValueTuple has invariant type parameters, + // Roslyn merges tuple element types in a covariant manner. + var merged = MergeSimilarTypes( + ta.ElementTypes[i], tb.ElementTypes[i], + variance.Combine(VarianceModifier.Covariant)); if (merged == null) return null; mergedElements.Add(merged); @@ -1062,28 +1072,75 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver ta.GetDefinition()?.ParentModule); } if (a is ParameterizedType pa && b is ParameterizedType pb - && pa.GenericType.Equals(pb.GenericType) && pa.TypeArguments.Count == pb.TypeArguments.Count) { + var genericType = MergeSimilarTypes(pa.GenericType, pb.GenericType, variance); + if (genericType == null) + return null; var mergedArgs = new IType[pa.TypeArguments.Count]; for (int i = 0; i < pa.TypeArguments.Count; i++) { - var merged = MergeSimilarTypes(pa.TypeArguments[i], pb.TypeArguments[i]); + var merged = MergeSimilarTypes( + pa.TypeArguments[i], pb.TypeArguments[i], + variance.Combine(pa.TypeParameters[i].Variance)); if (merged == null) return null; mergedArgs[i] = merged; } - return new ParameterizedType(pa.GenericType, mergedArgs); + return new ParameterizedType(genericType, mergedArgs); } if (a is ArrayType arrA && b is ArrayType arrB && arrA.Dimensions == arrB.Dimensions) { - var mergedElem = MergeSimilarTypes(arrA.ElementType, arrB.ElementType); + // Roslyn ArrayTypeSymbol merges in a covariant manner. + var mergedElem = MergeSimilarTypes( + arrA.ElementType, arrB.ElementType, + variance.Combine(VarianceModifier.Covariant)); if (mergedElem == null) return null; - return new ArrayType(arrA.Compilation, mergedElem, arrA.Dimensions, arrA.Nullability); + var nullability = MergeNullability(arrA.Nullability, arrB.Nullability, variance); + return new ArrayType(arrA.Compilation, mergedElem, arrA.Dimensions, nullability); + } + if (a is PointerType ptrA && b is PointerType ptrB) + { + var mergedElem = MergeSimilarTypes( + ptrA.ElementType, ptrB.ElementType, + variance.Combine(VarianceModifier.Invariant)); + if (mergedElem == null) + return null; + return new PointerType(mergedElem); } return null; } + + static Nullability MergeNullability(Nullability a, Nullability b, VarianceModifier variance) + { + // Like Roslyn's MergeNullableAnnotation() + return (variance, a, b) switch { + // Covariant merging rules: Nullable wins over Oblivious which wins over NotNullable. + (VarianceModifier.Covariant, Nullability.Nullable, _) => Nullability.Nullable, + (VarianceModifier.Covariant, _, Nullability.Nullable) => Nullability.Nullable, + (VarianceModifier.Covariant, Nullability.Oblivious, _) => Nullability.Oblivious, + (VarianceModifier.Covariant, _, Nullability.Oblivious) => Nullability.Oblivious, + (VarianceModifier.Covariant, Nullability.NotNullable, Nullability.NotNullable) => Nullability.NotNullable, + // Contravariant merging rules: NotNullable wins over Oblivious which wins over Nullable. + (VarianceModifier.Contravariant, Nullability.NotNullable, _) => Nullability.NotNullable, + (VarianceModifier.Contravariant, _, Nullability.NotNullable) => Nullability.NotNullable, + (VarianceModifier.Contravariant, Nullability.Oblivious, _) => Nullability.Oblivious, + (VarianceModifier.Contravariant, _, Nullability.Oblivious) => Nullability.Oblivious, + (VarianceModifier.Contravariant, Nullability.Nullable, Nullability.Nullable) => Nullability.Nullable, + // Invariant merging rules: NotNullable wins over Nullable which wins over Oblivious. + // Weird but that's what Roslyn does: + // static T M(ref T x, ref T y) => x; + // M(ref nullableArray, nonNullableArray); + // T is inferred as int[] and then the first argument reports a "possible null reference assignment" warning. + (VarianceModifier.Invariant, Nullability.NotNullable, _) => Nullability.NotNullable, + (VarianceModifier.Invariant, _, Nullability.NotNullable) => Nullability.NotNullable, + (VarianceModifier.Invariant, Nullability.Nullable, _) => Nullability.Nullable, + (VarianceModifier.Invariant, _, Nullability.Nullable) => Nullability.Nullable, + (VarianceModifier.Invariant, Nullability.Oblivious, Nullability.Oblivious) => Nullability.Oblivious, + _ => throw new NotSupportedException("Unexpected nullability combination: " + a + ", " + b + " with variance " + variance) + }; + } #endregion #region Finding the best common type of a set of expressions @@ -1166,28 +1223,33 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver // Deduplicate types. This also merges types that differ only in tuple // element names and/or object/dynamic. var candidateMergeDict = new Dictionary(); - foreach (var candidate in lowerBounds.Concat(upperBounds)) + void AddCandidates(IEnumerable candidates, VarianceModifier variance) { - var key = candidate.AcceptVisitor(NormalizeTypeVisitor.KeyForTypeMerging); - if (candidateMergeDict.TryGetValue(key, out var existing)) + foreach (var candidate in candidates) { - var merged = MergeSimilarTypes(existing, candidate); - Log.WriteLine(" Merged similar types " + existing + " and " + candidate + " into " + merged); - if (merged != null) + var key = candidate.AcceptVisitor(NormalizeTypeVisitor.KeyForTypeMerging); + if (candidateMergeDict.TryGetValue(key, out var existing)) { - candidateMergeDict[key] = merged; + var merged = MergeSimilarTypes(existing, candidate, variance); + Log.WriteLine(" Merged similar types " + existing + " and " + candidate + " into " + merged); + if (merged != null) + { + candidateMergeDict[key] = merged; + } + else + { + Debug.Fail("MergeSimilarTypes should always be able to merge;" + + " is the KeyForTypeMerging visitor misconfigured?"); + } } else { - Debug.Fail("MergeSimilarTypes should always be able to merge;" - + " is the KeyForTypeMerging visitor misconfigured?"); + candidateMergeDict.Add(key, candidate); } } - else - { - candidateMergeDict.Add(key, candidate); - } } + AddCandidates(lowerBounds, VarianceModifier.Covariant); + AddCandidates(upperBounds, VarianceModifier.Contravariant); Log.WriteCollection("FindTypesInBound, Merged types from bounds=", candidateMergeDict.Values); diff --git a/ICSharpCode.Decompiler/TypeSystem/ITypeParameter.cs b/ICSharpCode.Decompiler/TypeSystem/ITypeParameter.cs index c32ff410f..4a8d9d7dc 100644 --- a/ICSharpCode.Decompiler/TypeSystem/ITypeParameter.cs +++ b/ICSharpCode.Decompiler/TypeSystem/ITypeParameter.cs @@ -129,19 +129,32 @@ namespace ICSharpCode.Decompiler.TypeSystem /// /// Represents the variance of a type parameter. /// - public enum VarianceModifier : byte + public enum VarianceModifier : sbyte { /// /// The type parameter is not variant. /// - Invariant, + Invariant = 0, /// /// The type parameter is covariant (used in output position). /// - Covariant, + Covariant = 1, /// /// The type parameter is contravariant (used in input position). /// - Contravariant + Contravariant = -1 }; + + static class VarianceExtensions + { + /// + /// Combines variance modifiers. + /// It's like a multiplication where Invariant is 0, Covariant is +1 and Contravariant is -1. + /// + public static VarianceModifier Combine(this VarianceModifier a, VarianceModifier b) + { + // By picking matching enum values, we can actually implement this as multiplication. + return (VarianceModifier)((sbyte)a * (sbyte)b); + } + } } diff --git a/ICSharpCode.Decompiler/TypeSystem/NormalizeTypeVisitor.cs b/ICSharpCode.Decompiler/TypeSystem/NormalizeTypeVisitor.cs index 14c4fe91a..6d25464e7 100644 --- a/ICSharpCode.Decompiler/TypeSystem/NormalizeTypeVisitor.cs +++ b/ICSharpCode.Decompiler/TypeSystem/NormalizeTypeVisitor.cs @@ -65,7 +65,7 @@ namespace ICSharpCode.Decompiler.TypeSystem RemoveTupleElementNames = true, RemoveModOpt = false, RemoveModReq = false, - RemoveNullability = false, + RemoveNullability = true, }; internal static readonly NormalizeTypeVisitor IgnoreNullability = new NormalizeTypeVisitor { From 9aa57a118858b42f56ea3c4892db526b99c8c14f Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sat, 29 Aug 2026 21:56:59 +0200 Subject: [PATCH 3/8] Merge top-level nullability even across non-equivalent bounds. --- .../Semantics/TypeInferenceTests.cs | 16 +++++++- .../CSharp/Resolver/TypeInference.cs | 37 ++++++++++++++++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs index e70ab6579..8ff5c4dde 100644 --- a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs +++ b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs @@ -1257,14 +1257,26 @@ namespace ICSharpCode.Decompiler.Tests.Semantics Assert.That(success); } + [Test] + public void BestCommonTypeObjectAndNullableString() + { + Assert.That( + ti.GetBestCommonType([ + new ResolveResult(compilation.FindType(KnownTypeCode.Object).ChangeNullability(Nullability.NotNullable)), + new ResolveResult(compilation.FindType(KnownTypeCode.String).ChangeNullability(Nullability.Nullable)) + ], out bool success), + Is.EqualTo(compilation.FindType(KnownTypeCode.Object).ChangeNullability(Nullability.Nullable))); + Assert.That(success); + } + [Test] public void BestCommonTypeObjectAndNullableObject() { Assert.That( - ti.GetBestCommonType(new[] { + ti.GetBestCommonType([ new ResolveResult(compilation.FindType(KnownTypeCode.Object).ChangeNullability(Nullability.NotNullable)), new ResolveResult(compilation.FindType(KnownTypeCode.Object).ChangeNullability(Nullability.Nullable)) - }, out bool success), + ], out bool success), Is.EqualTo(compilation.FindType(KnownTypeCode.Object).ChangeNullability(Nullability.Nullable))); Assert.That(success); } diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs index 959982ec9..d668795bd 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs @@ -1222,16 +1222,32 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver // Deduplicate types. This also merges types that differ only in tuple // element names and/or object/dynamic. + // The specification doesn't mention this step, but Roslyn does it, + // and it's crucial for tuple element names and nested nullabilities in otherwise + // equivalent types. + Nullability? topLevelNullability = null; var candidateMergeDict = new Dictionary(); - void AddCandidates(IEnumerable candidates, VarianceModifier variance) + void AddCandidates(IReadOnlyCollection bounds, VarianceModifier variance) { - foreach (var candidate in candidates) + // This helper function works like Roslyn's MethodTypeInference.AddAllCandidates(). + // It deduplicates similar types and merges them into a single candidate type, + // handling differences in nullability, tuple element names, and object/dynamic, + // but only if the type is otherwise completely identical. + foreach (var bound in bounds) { - var key = candidate.AcceptVisitor(NormalizeTypeVisitor.KeyForTypeMerging); + if (topLevelNullability.HasValue) + { + topLevelNullability = MergeNullability(topLevelNullability.Value, bound.Nullability, variance); + } + else + { + topLevelNullability = bound.Nullability; + } + var key = bound.AcceptVisitor(NormalizeTypeVisitor.KeyForTypeMerging); if (candidateMergeDict.TryGetValue(key, out var existing)) { - var merged = MergeSimilarTypes(existing, candidate, variance); - Log.WriteLine(" Merged similar types " + existing + " and " + candidate + " into " + merged); + var merged = MergeSimilarTypes(existing, bound, variance); + Log.WriteLine(" Merged similar types " + existing + " and " + bound + " into " + merged); if (merged != null) { candidateMergeDict[key] = merged; @@ -1244,7 +1260,7 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver } else { - candidateMergeDict.Add(key, candidate); + candidateMergeDict.Add(key, bound); } } } @@ -1267,6 +1283,15 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver c => candidateTypes.All(o => conversions.ImplicitConversion(o, c).IsValid) ).ToList(); + // Apply the merged top-level nullability: + Debug.Assert(topLevelNullability.HasValue); + // (Roslyn has a different approach in MergeOrRemoveCandidates, but to me that just looked + // like an overly complicated way of achieving the same thing.) + for (int i = 0; i < candidateTypes.Count; i++) + { + candidateTypes[i] = candidateTypes[i].ChangeNullability(topLevelNullability.Value); + } + // If the specified algorithm produces a single candidate, we return // that candidate. // We also return the whole candidate list if we're not using the improved From 2057cd938dfdc31e1de7150755cd55d14ac9f6eb Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sat, 29 Aug 2026 22:01:45 +0200 Subject: [PATCH 4/8] Add back Log.Unindent() call that I accidentally deleted. --- ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs index d668795bd..53816806c 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs @@ -1007,6 +1007,7 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver // so bounds that differ only in tuple element names don't survive into // FindTypesInBounds as distinct candidates. var types = CreateNestedInstance().FindTypesInBounds(tp.LowerBounds, tp.UpperBounds); + Log.Unindent(); if (algorithm == TypeInferenceAlgorithm.ImprovedReturnAllResults) { tp.FixedTo = IntersectionType.Create(types); From 3d10a049967a6eb308a38711fb74d95914d21147 Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sat, 29 Aug 2026 23:53:40 +0200 Subject: [PATCH 5/8] Avoid breaking LINQ query expressions with nullable reference types. --- .../TestCases/Pretty/NullableRefTypes.cs | 18 ++++++++++++++++++ .../Semantics/LambdaResolveResult.cs | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/NullableRefTypes.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/NullableRefTypes.cs index fa6d934c5..b629a2e7e 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/NullableRefTypes.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/NullableRefTypes.cs @@ -3,6 +3,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty { @@ -207,4 +208,21 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty return default; } } + + public class T09_Linq + { + public IEnumerable QueryWithNonNullableReferenceTypes(IEnumerable strings) + { + return from s in strings + where s.Length > 0 + select s.ToUpper(); + } + + public IEnumerable QueryWithNullableReferenceTypes(IEnumerable strings) + { + return from s in strings + where s != null + select s.ToUpper(); + } + } } diff --git a/ICSharpCode.Decompiler/Semantics/LambdaResolveResult.cs b/ICSharpCode.Decompiler/Semantics/LambdaResolveResult.cs index 4bc1269e1..fd09a4875 100644 --- a/ICSharpCode.Decompiler/Semantics/LambdaResolveResult.cs +++ b/ICSharpCode.Decompiler/Semantics/LambdaResolveResult.cs @@ -165,7 +165,7 @@ namespace ICSharpCode.Decompiler.Semantics return Conversion.None; for (int i = 0; i < parameterTypes.Length; ++i) { - if (parameterTypes[i].Equals(this.Parameters[i].Type)) + if (NormalizeTypeVisitor.IgnoreNullability.EquivalentTypes(parameterTypes[i], this.Parameters[i].Type)) { continue; } From 26f2a22367644133c4c51720b65a78593d4d3b3f Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sun, 30 Aug 2026 00:41:08 +0200 Subject: [PATCH 6/8] Do not use LINQ query expressions when explicit type arguments were needed. --- .../CSharp/Transforms/IntroduceQueryExpressions.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/IntroduceQueryExpressions.cs b/ICSharpCode.Decompiler/CSharp/Transforms/IntroduceQueryExpressions.cs index 585dcf3b7..5714d6abe 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/IntroduceQueryExpressions.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/IntroduceQueryExpressions.cs @@ -142,6 +142,8 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms MemberReferenceExpression? mre = invocation.Target as MemberReferenceExpression; if (mre == null || IsNullConditional(mre.Target)) return null; + if (mre.TypeArguments.Count > 0) + return null; switch (mre.MemberName) { case "Select": From fa3de7bfa4b7b97b394e5ad304ea283b772fb996 Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sun, 30 Aug 2026 12:09:40 +0200 Subject: [PATCH 7/8] MergeSimilarTypes: add support for more kinds of types --- .../Semantics/TypeInferenceTests.cs | 25 ++++++++++ .../CSharp/Resolver/TypeInference.cs | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs index 8ff5c4dde..605d76e27 100644 --- a/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs +++ b/ICSharpCode.Decompiler.Tests/Semantics/TypeInferenceTests.cs @@ -23,6 +23,7 @@ using System.Collections.Immutable; using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Reflection.Metadata; using ICSharpCode.Decompiler.CSharp.Resolver; using ICSharpCode.Decompiler.Metadata; @@ -713,6 +714,15 @@ namespace ICSharpCode.Decompiler.Tests.Semantics ImmutableArray.CreateRange(elementNames)); } + FunctionPointerType MakeFunctionPointerType(ICompilation comp, IType returnType) + { + return new FunctionPointerType( + (MetadataModule)comp.MainModule, + SignatureCallingConvention.Default, ImmutableArray.Empty, + returnType, returnIsRefReadOnly: false, + ImmutableArray.Empty, ImmutableArray.Empty); + } + [Test] public void BestCommonTypeMergesTupleElementNames() { @@ -730,6 +740,21 @@ namespace ICSharpCode.Decompiler.Tests.Semantics Assert.That(success); } + [Test] + public void BestCommonTypeMergesFunctionPointerTupleElementNames() + { + var comp = RefAssemblyCompilation.Instance; + var inference = new TypeInference(comp); + + Assert.That( + inference.GetBestCommonType(new[] { + new ResolveResult(MakeFunctionPointerType(comp, MakeTupleType(comp, "a", "b"))), + new ResolveResult(MakeFunctionPointerType(comp, MakeTupleType(comp, "a", "c"))) + }, out bool success), + Is.EqualTo(MakeFunctionPointerType(comp, MakeTupleType(comp, "a", null)))); + Assert.That(success); + } + [Test] public void FixingMergesTupleElementNamesOfExactAndLowerBounds() { diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs index 53816806c..f81bc7b50 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs @@ -1101,6 +1101,15 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver var nullability = MergeNullability(arrA.Nullability, arrB.Nullability, variance); return new ArrayType(arrA.Compilation, mergedElem, arrA.Dimensions, nullability); } + if (a is ByReferenceType refA && b is ByReferenceType refB) + { + var mergedElem = MergeSimilarTypes( + refA.ElementType, refB.ElementType, + variance.Combine(VarianceModifier.Invariant)); + if (mergedElem == null) + return null; + return new ByReferenceType(mergedElem); + } if (a is PointerType ptrA && b is PointerType ptrB) { var mergedElem = MergeSimilarTypes( @@ -1110,6 +1119,47 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver return null; return new PointerType(mergedElem); } + if (a is FunctionPointerType fnPtrA && b is FunctionPointerType fnPtrB + && fnPtrA.CallingConvention == fnPtrB.CallingConvention + && fnPtrA.CustomCallingConventions.SequenceEqual(fnPtrB.CustomCallingConventions) + && fnPtrA.ReturnIsRefReadOnly == fnPtrB.ReturnIsRefReadOnly + && fnPtrA.ParameterTypes.Length == fnPtrB.ParameterTypes.Length + && fnPtrA.ParameterReferenceKinds.SequenceEqual(fnPtrB.ParameterReferenceKinds)) + { + var mergedReturn = MergeSimilarTypes( + fnPtrA.ReturnType, fnPtrB.ReturnType, + variance.Combine(VarianceModifier.Covariant)); + if (mergedReturn == null) + return null; + var mergedParameters = ImmutableArray.CreateBuilder(fnPtrA.ParameterTypes.Length); + for (int i = 0; i < fnPtrA.ParameterTypes.Length; i++) + { + var mergedParameter = MergeSimilarTypes( + fnPtrA.ParameterTypes[i], fnPtrB.ParameterTypes[i], + variance.Combine(VarianceModifier.Contravariant)); + if (mergedParameter == null) + return null; + mergedParameters.Add(mergedParameter); + } + return fnPtrA.WithSignature(mergedReturn, mergedParameters.MoveToImmutable()); + } + if (a is ModifiedType modA && b is ModifiedType modB + && modA.Kind == modB.Kind + && modA.Modifier.Equals(modB.Modifier)) + { + var mergedElem = MergeSimilarTypes(modA.ElementType, modB.ElementType, variance); + if (mergedElem == null) + return null; + return new ModifiedType(modA.Modifier, mergedElem, modA.Kind == TypeKind.ModReq); + } + if (a is UnknownType unknownTypeA && b is UnknownType unknownTypeB + && unknownTypeA.FullTypeName == unknownTypeB.FullTypeName) + { + if (unknownTypeA.IsReferenceType == unknownTypeB.IsReferenceType) + return unknownTypeA; + else + return unknownTypeA.WithoutReferenceTypeKnowledge(); + } return null; } From 99613cc797ff6e15177937c5f9f4c57b9640a687 Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sun, 30 Aug 2026 12:22:36 +0200 Subject: [PATCH 8/8] Fix comment + LINQ: validate type arguments in ThenBy-chain. --- ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs | 7 +++++-- .../CSharp/Transforms/IntroduceQueryExpressions.cs | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs index f81bc7b50..b1e678cd1 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/TypeInference.cs @@ -1336,8 +1336,11 @@ namespace ICSharpCode.Decompiler.CSharp.Resolver // Apply the merged top-level nullability: Debug.Assert(topLevelNullability.HasValue); - // (Roslyn has a different approach in MergeOrRemoveCandidates, but to me that just looked - // like an overly complicated way of achieving the same thing.) + // Roslyn has a different approach in MergeOrRemoveCandidates, which can + // differ in behavior when there's both lower+upper bounds + // -- e.g. `static void M(T x, Action a)` called with `M("s", (object? o) => {}))` + // is inferred as `T = object?` by Roslyn, but `T = object` by us. + // To match Roslyn exactly, we'd need to handle the topLevelNullability per-candidate. for (int i = 0; i < candidateTypes.Count; i++) { candidateTypes[i] = candidateTypes[i].ChangeNullability(topLevelNullability.Value); diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/IntroduceQueryExpressions.cs b/ICSharpCode.Decompiler/CSharp/Transforms/IntroduceQueryExpressions.cs index 5714d6abe..c9c5064f2 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/IntroduceQueryExpressions.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/IntroduceQueryExpressions.cs @@ -348,7 +348,7 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms static bool IsComplexQuery(MemberReferenceExpression mre) { - return ((mre.Target is InvocationExpression && mre.Parent is InvocationExpression) || mre.Parent?.Parent is QueryClause); + return (mre.Target is InvocationExpression && mre.Parent is InvocationExpression) || mre.Parent?.Parent is QueryClause; } QueryFromClause MakeFromClause(ParameterDeclaration parameter, Expression body) @@ -414,6 +414,8 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms return false; if (parameter.Name != expectedParameterName) return false; + if (mre.TypeArguments.Count > 0) + return false; if (mre.MemberName == "OrderBy" || mre.MemberName == "OrderByDescending") return !IsNullConditional(mre.Target);