From 3c9ea6dbca0e5baa5bdc0b483aeee79fe19ae579 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 23:18:42 +0000 Subject: [PATCH 1/3] Add regression tests for the remaining open LINQ issues The conversions for #932 (Group By keys named differently from their members) and #1302 (generic method inference with a contravariant base-type comparer) are already correct; these tests pin them, including running the VB and converted C#. #895 is already covered by Issue895_LinqWhereAfterGroupAsync and TestWhereAfterGroup. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019FEr8GSsdfh4WNGNkwrb9G --- .../ExpressionTests/LinqExpressionTests.cs | 42 ++++++++++++++ .../ExpressionTests/LinqQueryGroupByTests.cs | 57 +++++++++++++++++++ .../VBToCS/LinqExpressionTests.vb | 40 +++++++++++++ 3 files changed, 139 insertions(+) diff --git a/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs b/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs index 6df8eefb4..337aa27cb 100644 --- a/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs +++ b/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs @@ -959,6 +959,48 @@ public IQueryable M(IQueryable rs) where r.B == true && r.N > 1 || r.N2 > 1 && r.B == true select r; } +}"); + } + + /// + /// VB infers Distinct's type argument from the receiver alone, so the contravariant comparer's conversion is made explicit to keep C# inferring the same type + /// + [Fact] + public async Task Issue1302_GenericMethodInferenceWithBaseTypeComparerAsync() + { + await TestConversionVisualBasicToCSharpAsync(@"Imports System.Collections.Generic +Imports System.Linq + +Public Class BaseValue + Public Property Key As Integer +End Class + +Public Class DerivedValue + Inherits BaseValue +End Class + +Public Module Helpers + Public Function ToDictionary(values As IEnumerable(Of DerivedValue)) As Dictionary(Of Integer, DerivedValue) + Return values.Distinct(EqualityComparer(Of BaseValue).Default).ToDictionary(Function(value) value.Key) + End Function +End Module", @"using System.Collections.Generic; +using System.Linq; + +public partial class BaseValue +{ + public int Key { get; set; } +} + +public partial class DerivedValue : BaseValue +{ +} + +public static partial class Helpers +{ + public static Dictionary ToDictionary(IEnumerable values) + { + return values.Distinct((IEqualityComparer)EqualityComparer.Default).ToDictionary(value => value.Key); + } }"); } } diff --git a/Tests/CSharp/ExpressionTests/LinqQueryGroupByTests.cs b/Tests/CSharp/ExpressionTests/LinqQueryGroupByTests.cs index cd10d7cfb..6672a9d1a 100644 --- a/Tests/CSharp/ExpressionTests/LinqQueryGroupByTests.cs +++ b/Tests/CSharp/ExpressionTests/LinqQueryGroupByTests.cs @@ -365,6 +365,63 @@ group x by x.Length into Group orderby Count descending select Group.Key; } +}"); + } + + [Fact] + public async Task Issue932_GroupByKeysNamedDifferentlyFromTheirMembersAsync() + { + await TestConversionVisualBasicToCSharpAsync(@"Imports System.Linq +Public Class Entity + Public Property Entity_ID As Integer + Public Property First_Name As String + Public Property Last_Name As String +End Class +Public Class Policy + Public Property Agent_Entity_ID As Integer + Public Property Insured_Entity_ID As Integer +End Class +Public Class C + Sub M(policies As IQueryable(Of Policy), entities As IQueryable(Of Entity)) + Dim results = (From pCurrent In policies + Join agent In entities On agent.Entity_ID Equals pCurrent.Agent_Entity_ID + Join insured In entities On insured.Entity_ID Equals pCurrent.Insured_Entity_ID + Group By + Agent_First_Name = agent.First_Name, + Agent_Last_Name = agent.Last_Name, + Insured_First_Name = insured.First_Name, + Insured_Last_Name = insured.Last_Name Into Group1 = Group + Select + Agent_First_Name = Agent_First_Name, + Agent_Last_Name = Agent_Last_Name, + Insured_First_Name = Insured_First_Name, + Insured_Last_Name = Insured_Last_Name).ToList() + End Sub +End Class", @"using System.Linq; + +public partial class Entity +{ + public int Entity_ID { get; set; } + public string First_Name { get; set; } + public string Last_Name { get; set; } +} + +public partial class Policy +{ + public int Agent_Entity_ID { get; set; } + public int Insured_Entity_ID { get; set; } +} + +public partial class C +{ + public void M(IQueryable policies, IQueryable entities) + { + var results = (from pCurrent in policies + join agent in entities on pCurrent.Agent_Entity_ID equals agent.Entity_ID + join insured in entities on pCurrent.Insured_Entity_ID equals insured.Entity_ID + group new { pCurrent, agent, insured } by new { Agent_First_Name = agent.First_Name, Agent_Last_Name = agent.Last_Name, Insured_First_Name = insured.First_Name, Insured_Last_Name = insured.Last_Name } into Group1 + select new { Group1.Key.Agent_First_Name, Group1.Key.Agent_Last_Name, Group1.Key.Insured_First_Name, Group1.Key.Insured_Last_Name }).ToList(); + } }"); } } diff --git a/Tests/TestData/SelfVerifyingTests/VBToCS/LinqExpressionTests.vb b/Tests/TestData/SelfVerifyingTests/VBToCS/LinqExpressionTests.vb index ea39a038d..2d5e0c22b 100644 --- a/Tests/TestData/SelfVerifyingTests/VBToCS/LinqExpressionTests.vb +++ b/Tests/TestData/SelfVerifyingTests/VBToCS/LinqExpressionTests.vb @@ -556,4 +556,44 @@ Public Class LinqExpressionTests Assert.Equal(3, total) End Sub + Public Class Person + Public Property Id As Integer + Public Property Name As String + Public Sub New(id As Integer, name As String) + Me.Id = id + Me.Name = name + End Sub + End Class + + + Sub TestGroupByKeysNamedDifferentlyFromTheirMembers() + Dim people = {New Person(1, "a"), New Person(2, "b")} + Dim pairs = {Tuple.Create(1, 2), Tuple.Create(1, 2), Tuple.Create(2, 1)} + Dim q = From p In pairs + Join agent In people On agent.Id Equals p.Item1 + Join insured In people On insured.Id Equals p.Item2 + Group By Agent_Name = agent.Name, Insured_Name = insured.Name Into Group1 = Group + Select Agent_Name = Agent_Name, Insured_Name = Insured_Name, Count = Group1.Count() + Assert.Equal("{ Agent_Name = a, Insured_Name = b, Count = 2 }|{ Agent_Name = b, Insured_Name = a, Count = 1 }", Show(q)) + End Sub + + Public Class BaseValue + Public Property Key As Integer + End Class + + Public Class DerivedValue + Inherits BaseValue + End Class + + Private Shared Function ToDictionary(values As IEnumerable(Of DerivedValue)) As Dictionary(Of Integer, DerivedValue) + Return values.Distinct(EqualityComparer(Of BaseValue).Default).ToDictionary(Function(value) value.Key) + End Function + + + Sub TestGenericMethodInferenceWithBaseTypeComparer() + Dim value = New DerivedValue With {.Key = 1} + Dim result = ToDictionary({value, value}) + Assert.Same(value, result(1)) + End Sub + End Class From 3a93c8b515665cbd8d4829864416ac2196aefa5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 01:01:57 +0000 Subject: [PATCH 2/3] State type arguments VB inferred from the receiver when an argument needs a variance conversion VB infers an extension method's type arguments from the receiver first, then converts the other arguments, whereas C# infers from all of them together: `values.Distinct(EqualityComparer(Of BaseValue).Default)` makes C# infer BaseValue. The test for #1302 previously passed only because, in some environments, the converter happened to cast the comparer; CI's references don't produce that cast. Fixes #1302 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019FEr8GSsdfh4WNGNkwrb9G --- .../CSharp/NameExpressionNodeVisitor.cs | 49 +++++++++++++++++++ .../ExpressionTests/LinqExpressionTests.cs | 38 +++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/CodeConverter/CSharp/NameExpressionNodeVisitor.cs b/CodeConverter/CSharp/NameExpressionNodeVisitor.cs index c7efe2e32..31347a629 100644 --- a/CodeConverter/CSharp/NameExpressionNodeVisitor.cs +++ b/CodeConverter/CSharp/NameExpressionNodeVisitor.cs @@ -256,6 +256,53 @@ await WithRemovedRedundantConversionOrNullAsync(node, expressionSymbol)) is { } return await ConvertInvocationAsync(node, invocationSymbol, expressionSymbol); } + /// + /// VB infers an extension method's type arguments from the receiver first, then converts the other arguments to fit, + /// whereas C# infers from all arguments together, e.g. `values.Distinct(EqualityComparer(Of BaseValue).Default)` would make C# infer BaseValue. + /// So where an argument only fits its generic parameter via a variance conversion, state the type arguments VB inferred. + /// + private ExpressionSyntax WithTypeArgumentsInferredByVb(IOperation operation, ExpressionSyntax convertedExpression) + { + if (operation is not IInvocationOperation { TargetMethod: var method } invocation || + (method.ReducedFrom ?? method.ConstructedFrom.ReducedFrom) is not { IsGenericMethod: true } extension || + convertedExpression is not MemberAccessExpressionSyntax { Name: IdentifierNameSyntax name } memberAccess || + !invocation.Arguments.Any(a => IsConvertedToFitGenericParameter(a, extension))) { + return convertedExpression; + } + var typeArguments = extension.TypeParameters.Select(t => GetInferredTypeArgument(method, t)).ToList(); + if (typeArguments.Any(t => t is null or ITypeParameterSymbol || t.IsAnonymousType)) return convertedExpression; + var typeArgumentList = SyntaxFactory.TypeArgumentList(SyntaxFactory.SeparatedList(typeArguments.Select(t => CommonConversions.GetTypeSyntax(t)))); + return memberAccess.WithName(SyntaxFactory.GenericName(name.Identifier, typeArgumentList)); + } + + /// + /// VB fixes some type parameters from the receiver while reducing the method, and infers the rest from the arguments + /// + private static ITypeSymbol GetInferredTypeArgument(IMethodSymbol reducedMethod, ITypeParameterSymbol typeParameter) => + reducedMethod.ConstructedFrom.GetTypeInferredDuringReduction(typeParameter) ?? + reducedMethod.TypeParameters.Zip(reducedMethod.TypeArguments, (p, a) => (p, a)).FirstOrDefault(t => SymbolEqualityComparer.Default.Equals(t.p.ReducedFrom, typeParameter)).a; + + private static bool IsConvertedToFitGenericParameter(IArgumentOperation argument, IMethodSymbol extension) => + argument is { Value: IConversionOperation { IsImplicit: true, Operand.Type: { } argumentType }, Parameter: { Ordinal: var ordinal, Type: var parameterType } } && + ordinal + 1 < extension.Parameters.Length && ContainsTypeParameter(extension.Parameters[ordinal + 1].Type) && + IsVarianceConversion(argumentType, parameterType); + + /// + /// The argument implements the parameter's generic type with different type arguments, e.g. IEqualityComparer(Of Base) for IEqualityComparer(Of Derived) + /// + private static bool IsVarianceConversion(ITypeSymbol argumentType, ITypeSymbol parameterType) => + parameterType is INamedTypeSymbol { IsGenericType: true } genericParameter && + argumentType.GetBaseTypesAndThis().Concat(argumentType.AllInterfaces) + .Any(t => SymbolEqualityComparer.Default.Equals(t.OriginalDefinition, genericParameter.OriginalDefinition) && + !SymbolEqualityComparer.Default.Equals(t, genericParameter)); + + private static bool ContainsTypeParameter(ITypeSymbol type) => type switch { + ITypeParameterSymbol => true, + IArrayTypeSymbol array => ContainsTypeParameter(array.ElementType), + INamedTypeSymbol named => named.TypeArguments.Any(ContainsTypeParameter), + _ => false + }; + private async Task WithRemovedRedundantConversionOrNullAsync(VBSyntax.InvocationExpressionSyntax conversionNode, ISymbol invocationSymbol) { if (invocationSymbol?.ContainingNamespace.MetadataName != nameof(Microsoft.VisualBasic) || @@ -334,6 +381,8 @@ private async Task ConvertInvocationAsync(VBSyntax.InvocationE } } + convertedExpression = WithTypeArgumentsInferredByVb(operation, convertedExpression); + if (invocationSymbol is IMethodSymbol m && convertedExpression is LambdaExpressionSyntax) { convertedExpression = SyntaxFactory.ObjectCreationExpression(CommonConversions.GetFuncTypeSyntax(expressionType, m), ExpressionSyntaxExtensions.CreateArgList(convertedExpression), null); } diff --git a/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs b/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs index 337aa27cb..9ec97cd31 100644 --- a/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs +++ b/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs @@ -963,7 +963,7 @@ public IQueryable M(IQueryable rs) } /// - /// VB infers Distinct's type argument from the receiver alone, so the contravariant comparer's conversion is made explicit to keep C# inferring the same type + /// VB infers Distinct's type argument from the receiver first, whereas C# would infer BaseValue from the comparer too /// [Fact] public async Task Issue1302_GenericMethodInferenceWithBaseTypeComparerAsync() @@ -999,7 +999,41 @@ public static partial class Helpers { public static Dictionary ToDictionary(IEnumerable values) { - return values.Distinct((IEqualityComparer)EqualityComparer.Default).ToDictionary(value => value.Key); + return values.Distinct(EqualityComparer.Default).ToDictionary(value => value.Key); + } +}"); + } + + /// + /// C# infers the same type arguments when an argument implements the generic parameter type exactly + /// + [Fact] + public async Task GenericMethodWithInterfaceConversionKeepsTypeArgumentsInferredAsync() + { + await TestConversionVisualBasicToCSharpAsync(@"Imports System.Collections.Generic +Imports System.Linq + +Public Class Value + Public Property Name As String +End Class + +Public Module Helpers + Public Function ByName(values As IEnumerable(Of Value)) As Dictionary(Of String, Value) + Return values.ToDictionary(Function(v) v.Name, EqualityComparer(Of String).Default) + End Function +End Module", @"using System.Collections.Generic; +using System.Linq; + +public partial class Value +{ + public string Name { get; set; } +} + +public static partial class Helpers +{ + public static Dictionary ByName(IEnumerable values) + { + return values.ToDictionary(v => v.Name, EqualityComparer.Default); } }"); } From aa31fbb66f83a135b2dc0e0ac9c40364a7484a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 02:21:17 +0000 Subject: [PATCH 3/3] State VB-inferred type arguments for generic callers and conditional access The #1302 fix skipped any inferred type parameter, including the caller's own nameable ones, and only handled member access, not ?. member binding, so both shapes still made C# infer the base type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019FEr8GSsdfh4WNGNkwrb9G --- .../CSharp/NameExpressionNodeVisitor.cs | 15 +++- .../ExpressionTests/LinqExpressionTests.cs | 84 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/CodeConverter/CSharp/NameExpressionNodeVisitor.cs b/CodeConverter/CSharp/NameExpressionNodeVisitor.cs index 31347a629..eb6de7f58 100644 --- a/CodeConverter/CSharp/NameExpressionNodeVisitor.cs +++ b/CodeConverter/CSharp/NameExpressionNodeVisitor.cs @@ -265,14 +265,23 @@ private ExpressionSyntax WithTypeArgumentsInferredByVb(IOperation operation, Exp { if (operation is not IInvocationOperation { TargetMethod: var method } invocation || (method.ReducedFrom ?? method.ConstructedFrom.ReducedFrom) is not { IsGenericMethod: true } extension || - convertedExpression is not MemberAccessExpressionSyntax { Name: IdentifierNameSyntax name } memberAccess || + convertedExpression is not (MemberAccessExpressionSyntax { Name: IdentifierNameSyntax } or MemberBindingExpressionSyntax { Name: IdentifierNameSyntax }) || !invocation.Arguments.Any(a => IsConvertedToFitGenericParameter(a, extension))) { return convertedExpression; } var typeArguments = extension.TypeParameters.Select(t => GetInferredTypeArgument(method, t)).ToList(); - if (typeArguments.Any(t => t is null or ITypeParameterSymbol || t.IsAnonymousType)) return convertedExpression; + if (typeArguments.Any(t => t is null || t.IsAnonymousType || IsCallsOwnTypeParameter(t))) return convertedExpression; var typeArgumentList = SyntaxFactory.TypeArgumentList(SyntaxFactory.SeparatedList(typeArguments.Select(t => CommonConversions.GetTypeSyntax(t)))); - return memberAccess.WithName(SyntaxFactory.GenericName(name.Identifier, typeArgumentList)); + return convertedExpression switch { + MemberAccessExpressionSyntax memberAccess => memberAccess.WithName(SyntaxFactory.GenericName(memberAccess.Name.Identifier, typeArgumentList)), + MemberBindingExpressionSyntax memberBinding => memberBinding.WithName(SyntaxFactory.GenericName(memberBinding.Name.Identifier, typeArgumentList)), + _ => convertedExpression + }; + + /// The enclosing method's or type's type parameters can be named, but the call's own uninferred ones can't + bool IsCallsOwnTypeParameter(ITypeSymbol type) => + type is ITypeParameterSymbol { DeclaringMethod: { } declaringMethod } && + (declaringMethod.ReducedFrom != null || SymbolEqualityComparer.Default.Equals(declaringMethod.OriginalDefinition, extension.OriginalDefinition)); } /// diff --git a/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs b/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs index 9ec97cd31..4ce0c82a4 100644 --- a/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs +++ b/Tests/CSharp/ExpressionTests/LinqExpressionTests.cs @@ -1004,6 +1004,90 @@ public static Dictionary ToDictionary(IEnumerable + /// The type argument VB inferred can be the calling method's own type parameter + /// + [Fact] + public async Task GenericMethodInferenceWithCallersTypeParameterAsync() + { + await TestConversionVisualBasicToCSharpAsync(@"Imports System.Collections.Generic +Imports System.Linq + +Public Class BaseValue + Public Property Key As Integer +End Class + +Public Class DerivedValue + Inherits BaseValue +End Class + +Public Module Helpers + Public Function DistinctValues(Of T As BaseValue)(values As IEnumerable(Of T)) As List(Of T) + Return values.Distinct(EqualityComparer(Of BaseValue).Default).ToList() + End Function +End Module", @"using System.Collections.Generic; +using System.Linq; + +public partial class BaseValue +{ + public int Key { get; set; } +} + +public partial class DerivedValue : BaseValue +{ +} + +public static partial class Helpers +{ + public static List DistinctValues(IEnumerable values) where T : BaseValue + { + return values.Distinct(EqualityComparer.Default).ToList(); + } +}"); + } + + /// + /// Conditional access converts to a member binding, which needs the same type arguments + /// + [Fact] + public async Task GenericMethodInferenceWithConditionalAccessAsync() + { + await TestConversionVisualBasicToCSharpAsync(@"Imports System.Collections.Generic +Imports System.Linq + +Public Class BaseValue + Public Property Key As Integer +End Class + +Public Class DerivedValue + Inherits BaseValue +End Class + +Public Module Helpers + Public Function DistinctValues(values As IEnumerable(Of DerivedValue)) As List(Of DerivedValue) + Return values?.Distinct(EqualityComparer(Of BaseValue).Default).ToList() + End Function +End Module", @"using System.Collections.Generic; +using System.Linq; + +public partial class BaseValue +{ + public int Key { get; set; } +} + +public partial class DerivedValue : BaseValue +{ +} + +public static partial class Helpers +{ + public static List DistinctValues(IEnumerable values) + { + return values?.Distinct(EqualityComparer.Default).ToList(); + } +}"); + } + /// /// C# infers the same type arguments when an argument implements the generic parameter type exactly ///