From cd181b3f08fe310ce56eaacd8aa8ad32c9fcf66f Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 30 Jul 2026 18:07:43 +0200 Subject: [PATCH] Fold C# 14 implicit span conversions instead of printing their helpers The C# 14 compiler lowers implicit span conversions to calls - MemoryExtensions.AsSpan(string), ReadOnlySpan.CastUp, and the span op_Implicit operators - so decompiled code showed the lowered form even though the conversion and betterness layers already implement the C# 14 rules. CallBuilder now folds those helper calls back into conversions, riding the existing mechanism: the conversion is built as an explicit cast, consumption sites make it implicit where the context allows, and the overload-resolution recheck re-adds a cast when the bare argument would bind to a different overload (which canonicalizes deliberate AsSpan disambiguations to the equivalent explicit span cast). Span conversions compose, so CastCanBeMadeImplicit lets a direct input-to-target span conversion replace a chained pair; and an rvalue bound to an in parameter gets the same chance to shed the cast as a by-value argument, since ChangeDirectionExpressionTo bypasses the by-value strip. Part of #829. Assisted-by: Claude:claude-fable-5:Claude Code --- .../ICSharpCode.Decompiler.Tests.csproj | 2 + .../ILPrettyTestRunner.cs | 6 ++ .../PrettyTestRunner.cs | 1 - .../SpanConversionOperatorMismatch.cs | 17 ++++++ .../SpanConversionOperatorMismatch.il | 57 +++++++++++++++++ .../Pretty/FirstClassSpanConversions.cs | 33 +++++++++- .../TestCases/Pretty/FirstClassSpanTypes.cs | 2 +- ICSharpCode.Decompiler/CSharp/CallBuilder.cs | 61 ++++++++++++++++++- .../CSharp/TranslatedExpression.cs | 7 +++ 9 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/ILPretty/SpanConversionOperatorMismatch.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/ILPretty/SpanConversionOperatorMismatch.il diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index e3fd7f88e..028dbba14 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -208,6 +208,8 @@ + + diff --git a/ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs index c1c6070b8..85615a133 100644 --- a/ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs @@ -303,6 +303,12 @@ namespace ICSharpCode.Decompiler.Tests await Run(); } + [Test] + public async Task SpanConversionOperatorMismatch() + { + await Run(settings: new DecompilerSettings { FileScopedNamespaces = false, FirstClassSpanTypes = true }); + } + [Test] public async Task ConstantBlobs() { diff --git a/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs index 82d29611d..3a16f4270 100644 --- a/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs @@ -809,7 +809,6 @@ namespace ICSharpCode.Decompiler.Tests [Test] public async Task FirstClassSpanConversions([ValueSource(nameof(roslyn5OrNewerOptions))] CompilerOptions cscOptions) { - Assert.Ignore("Implicit span conversions (C# 14 first-class span types) are not yet folded by the decompiler. See https://github.com/icsharpcode/ILSpy/issues/829"); await RunForLibrary(cscOptions: cscOptions); } diff --git a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/SpanConversionOperatorMismatch.cs b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/SpanConversionOperatorMismatch.cs new file mode 100644 index 000000000..c2dfcf0d4 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/SpanConversionOperatorMismatch.cs @@ -0,0 +1,17 @@ +using System; + +namespace ICSharpCode.Decompiler.Tests.TestCases.ILPretty +{ + public class SpanConversionOperatorMismatch + { + public static implicit operator ReadOnlySpan(object o) + { + return default(ReadOnlySpan); + } + + public static ReadOnlySpan ConvertString(string s) + { + return (ReadOnlySpan)(object)s; + } + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/SpanConversionOperatorMismatch.il b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/SpanConversionOperatorMismatch.il new file mode 100644 index 000000000..f26f3c531 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/SpanConversionOperatorMismatch.il @@ -0,0 +1,57 @@ +// Regression fixture: an implicit span conversion must not be folded into a call to a conversion +// operator that does not perform it. +// +// C# cannot declare 'implicit operator ReadOnlySpan(object)' - neither operand type is the +// declaring type - but IL can. An implicit span conversion from string to ReadOnlySpan does +// exist, yet the compiler emits it as MemoryExtensions.AsSpan, not as this operator, so the call +// below is a user-defined conversion and the cast to its parameter type has to survive. + +.assembly extern System.Runtime +{ + .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) + .ver 4:0:0:0 +} +.assembly SpanConversionOperatorMismatch +{ + .custom instance void [System.Runtime]System.Runtime.Versioning.TargetFrameworkAttribute::.ctor(string) = { + string('.NETCoreApp,Version=11.0') + } + .hash algorithm 0x00008004 + .ver 1:0:0:0 +} +.module SpanConversionOperatorMismatch.dll + +.class public auto ansi beforefieldinit ICSharpCode.Decompiler.Tests.TestCases.ILPretty.SpanConversionOperatorMismatch + extends [System.Runtime]System.Object +{ + .method public hidebysig specialname static + valuetype [System.Runtime]System.ReadOnlySpan`1 + op_Implicit(object o) cil managed + { + .maxstack 1 + .locals init (valuetype [System.Runtime]System.ReadOnlySpan`1 V_0) + IL_0000: ldloca.s V_0 + IL_0002: initobj valuetype [System.Runtime]System.ReadOnlySpan`1 + IL_0008: ldloc.0 + IL_0009: ret + } + + .method public hidebysig static + valuetype [System.Runtime]System.ReadOnlySpan`1 + ConvertString(string s) cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call valuetype [System.Runtime]System.ReadOnlySpan`1 ICSharpCode.Decompiler.Tests.TestCases.ILPretty.SpanConversionOperatorMismatch::op_Implicit(object) + IL_0006: ret + } + + .method public hidebysig specialname rtspecialname + instance void .ctor() cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance void [System.Runtime]System.Object::.ctor() + IL_0006: ret + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FirstClassSpanConversions.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FirstClassSpanConversions.cs index 8888496bd..4171ce9ed 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FirstClassSpanConversions.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FirstClassSpanConversions.cs @@ -30,6 +30,18 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty { } + internal class SpanConvertible + { + public static implicit operator ReadOnlySpan(SpanConvertible c) + { + return default(ReadOnlySpan); + } + } + + internal class DerivedSpanConvertible : SpanConvertible + { + } + public static void AcceptReadOnlySpanChar(ReadOnlySpan s) { } @@ -64,8 +76,10 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty public static int StringToReadOnlySpanCharLocal(string s) { + // The local is read twice so it survives decompilation; a single-use span local is + // inlined into its consumer by general decompiler policy, independent of this feature. ReadOnlySpan readOnlySpan = s; - return readOnlySpan.Length; + return readOnlySpan.Length + readOnlySpan.Length; } public static void VarianceReadOnlySpan(ReadOnlySpan s) @@ -88,6 +102,23 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty AcceptReadOnlySpanBase(a); } + public static ReadOnlySpan CovariantArrayThroughOperator(Derived[] a) + { + // ReadOnlySpan.op_Implicit(Base[]) applied to a Derived[]: array covariance + // means the IL passes the argument without a cast instruction, and the span + // conversion the call performs relates exactly these two types. + return a; + } + + public static ReadOnlySpan UserDefinedOperatorToSpan(DerivedSpanConvertible c) + { + // The operator is declared on the base type, so the conversion is user-defined even + // though its target is a span type. Only the user-defined conversion that resolves + // to this very operator may be folded into the cast; a span conversion never applies + // to a source type outside the language's own span-convertible set. + return c; + } + public static void InArgument(int[] a) { AcceptInReadOnlySpan(a); diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FirstClassSpanTypes.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FirstClassSpanTypes.cs index 82058e290..3b01982ca 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FirstClassSpanTypes.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FirstClassSpanTypes.cs @@ -173,7 +173,7 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty ObjectOrReadOnlySpan((object)arr); ObjectOrReadOnlySpanChar((object)str); EnumerableOrReadOnlySpan((IEnumerable)arr); - StringOrReadOnlySpanChar(str.AsSpan()); + StringOrReadOnlySpanChar((ReadOnlySpan)str); } } diff --git a/ICSharpCode.Decompiler/CSharp/CallBuilder.cs b/ICSharpCode.Decompiler/CSharp/CallBuilder.cs index 62e7d9f66..0bfb63143 100644 --- a/ICSharpCode.Decompiler/CSharp/CallBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/CallBuilder.cs @@ -329,6 +329,30 @@ namespace ICSharpCode.Decompiler.CSharp && method.Parameters[0].Type.IsKnownType(KnownTypeCode.String); } + /// + /// Matches MemoryExtensions.AsSpan(string), the helper the C# 14 compiler emits for the + /// implicit span conversion from string to ReadOnlySpan<char>. + /// + internal static bool IsStringToReadOnlySpanCharAsSpan(IMethod method) + { + return method is { IsStatic: true, Name: "AsSpan", Parameters.Count: 1, TypeArguments.Count: 0 } + && method.DeclaringType.FullName == "System.MemoryExtensions" + && method.ReturnType.IsKnownType(KnownTypeCode.ReadOnlySpanOfT) + && method.ReturnType.TypeArguments[0].IsKnownType(KnownTypeCode.Char) + && method.Parameters[0].Type.IsKnownType(KnownTypeCode.String); + } + + /// + /// Matches ReadOnlySpan<To>.CastUp<From>(ReadOnlySpan<From>), the helper the + /// C# 14 compiler emits for the covariant implicit span conversion. + /// + internal static bool IsReadOnlySpanCastUp(IMethod method) + { + return method is { IsStatic: true, Name: "CastUp", Parameters.Count: 1, TypeArguments.Count: 1 } + && method.DeclaringType.IsKnownType(KnownTypeCode.ReadOnlySpanOfT) + && method.Parameters[0].Type.IsKnownType(KnownTypeCode.ReadOnlySpanOfT); + } + public ExpressionWithResolveResult Build(OpCode callOpCode, IMethod method, IReadOnlyList callArguments, IReadOnlyList? argumentToParameterMap = null, @@ -481,6 +505,21 @@ namespace ICSharpCode.Decompiler.CSharp return HandleImplicitConversion(method, argumentList.Arguments[0]); } + if (settings.FirstClassSpanTypes && argumentList.Length == 1 + && (IsStringToReadOnlySpanCharAsSpan(method) || IsReadOnlySpanCastUp(method))) + { + // The C# 14 compiler emits these helpers for implicit span conversions; fold the + // call back into the conversion. Only safe when the conversion actually applies + // to this argument type - otherwise keep the call (e.g. AsSpan on a null literal). + var spanConv = CSharpConversions.Get(expressionBuilder.compilation) + .ImplicitConversion(argumentList.Arguments[0].Type, method.ReturnType); + if (spanConv.IsImplicitSpanConversion) + { + argumentList.CheckNoNamedOrOptionalArguments(); + return HandleImplicitConversion(method, argumentList.Arguments[0]); + } + } + if (settings.InlineArrays && method is { DeclaringType.FullName: "", Name: "InlineArrayAsSpan" or "InlineArrayAsReadOnlySpan" } && argumentList.Length == 2) @@ -1024,6 +1063,15 @@ namespace ICSharpCode.Decompiler.CSharp if (parameter.ReferenceKind != ReferenceKind.None) { arg = ExpressionBuilder.ChangeDirectionExpressionTo(arg, parameter.ReferenceKind, callArguments[i] is AddressOf); + // An rvalue bound to an 'in' parameter loses its DirectionExpression above and + // is an ordinary value expression: give a span conversion the same chance to + // become implicit that by-value arguments get from the ConvertTo call above. + if (arg.Expression is not DirectionExpression + && parameter.Type.SkipModifiers() is ByReferenceType brt + && arg.ResolveResult is ConversionResolveResult { Conversion.IsImplicitSpanConversion: true }) + { + arg = arg.ConvertTo(brt.ElementType, expressionBuilder, allowImplicitConversion: true); + } } arguments.Add(arg); @@ -1535,7 +1583,18 @@ namespace ICSharpCode.Decompiler.CSharp var conversions = CSharpConversions.Get(expressionBuilder.compilation); IType targetType = method.ReturnType; var conv = conversions.ImplicitConversion(argument.Type, targetType); - if (!(conv.IsUserDefined && conv.IsValid && conv.Method.Equals(method, NormalizeTypeVisitor.TypeErasure))) + // The compiler emits an implicit span conversion as a call to one of the span types' + // own members, so such a call is the conversion and folding it back is exact. Any + // other method reaching this point is a user-defined conversion operator, which only + // the user-defined conversion resolving to that very operator may be folded into. + bool spanConversionMember = method.DeclaringType.IsKnownType(KnownTypeCode.SpanOfT) + || method.DeclaringType.IsKnownType(KnownTypeCode.ReadOnlySpanOfT) + || IsStringToReadOnlySpanCharAsSpan(method); + bool directlyConvertible = conv.IsValid + && (conv.IsUserDefined + ? conv.Method.Equals(method, NormalizeTypeVisitor.TypeErasure) + : conv.IsImplicitSpanConversion && spanConversionMember); + if (!directlyConvertible) { // implicit conversion to targetType isn't directly possible, so first insert a cast to the argument type argument = argument.ConvertTo(method.Parameters[0].Type, expressionBuilder); diff --git a/ICSharpCode.Decompiler/CSharp/TranslatedExpression.cs b/ICSharpCode.Decompiler/CSharp/TranslatedExpression.cs index efbf1ee69..1a9ca1a85 100644 --- a/ICSharpCode.Decompiler/CSharp/TranslatedExpression.cs +++ b/ICSharpCode.Decompiler/CSharp/TranslatedExpression.cs @@ -652,6 +652,13 @@ namespace ICSharpCode.Decompiler.CSharp return newTargetType.IsKnownType(KnownTypeCode.FormattableString) || newTargetType.IsKnownType(KnownTypeCode.IFormattable); } + if (conversion.IsImplicitSpanConversion) + { + // Implicit span conversions compose: if the input converts to the new target + // directly, the result is the same span the two-step path produces. + return conversions.IdentityConversion(oldTargetType, newTargetType) + || conversions.ImplicitConversion(inputType, newTargetType).IsImplicitSpanConversion; + } return conversions.IdentityConversion(oldTargetType, newTargetType); }