Repository: icsharpcode/ILSpy
Pull request #3470: Handle explicit optional parameter after default parameter
Author: login: ds5678
Created: 2025-05-05T19:52:18Z | Merged: 2025-05-17T15:11:15Z

Resolves #3469 

### Solution
* Any comments on the approach taken, its consistency with surrounding code, etc.
  * I implemented this in an extensions class, which is a little bit removed from the code that uses it.
* Which part of this PR is most in need of attention/improvement?
  * Do you think my extension method needs more documentation?
  * Should there be any additional tests involving decimal constants?
* [x] At least one test covering the code changed



Linked issue #3469: Optional parameters with implicit default values
### Input code

```il
.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )                         // .?_....:
  .ver 4:0:0:0
}

.class public auto ansi abstract sealed beforefieldinit ICSharpCode.Decompiler.Tests.TestCases.ILPretty.Issue3469
    extends [System.Runtime]System.Object
{
    // Methods
    .method public hidebysig static 
        void M (
            [opt] int32 i,
            [opt] valuetype [System.Runtime]System.DateTime d
        ) cil managed 
    {
        .param [1] = int32(0)
        // Roslyn inserts, but Mono doesn't:
        // .param [2] = nullref
        // Method begins at RVA 0x2050
        // Code size 1 (0x1)
        .maxstack 8

        IL_0000: ret
    } // end of method Issue3469::M

} // end of class ICSharpCode.Decompiler.Tests.TestCases.ILPretty.Issue3469
```

### Erroneous output

```c#
using System;
using System.Runtime.InteropServices;
namespace ICSharpCode.Decompiler.Tests.TestCases.ILPretty
{
	public static class Issue3469
	{
		public static void M(int i = 0, [Optional] DateTime d)
		{
		}
 	}
}
```

```
CS1737: Optional parameter cannot precede required parameters
```

### Expected output

```c#
using System;
namespace ICSharpCode.Decompiler.Tests.TestCases.ILPretty
{
	public static class Issue3469
	{
		public static void M(int i = 0, DateTime d = default(DateTime))
		{
		}
 	}
}
```

### Details
* Product in use: IL pretty unit test
* Version in use: latest commit (aff9649711fa3579f46e40336e7dc3fb8192302c)
* Any other relevant information to the issue, or your interest in contributing a fix.
  * I am always willing to contribute fixes for my issues.
  * This assembly was compiled with the Unity Mono compiler.
  * If this is a step too far, I can automatically preprocess the assembly with AsmResolver before using ILSpy. I already do this in other situations, like removing public keys from `InternalsVisibleToAttribute`.
Linked issue comments: user: login: ds5678
created_at: 2025-05-05T04:03:10Z
body: > If this is a step too far, I can automatically preprocess the assembly with AsmResolver before using ILSpy.

Serious. There'd be no hard feelings if you don't want it in the decompiler. A preprocessor is trivial:

```cs
foreach (MethodDefinition method in manager.GetAllMethods())
{
	foreach (Parameter parameter in method.Parameters)
	{
		ParameterDefinition? definition = parameter.Definition;
		if (definition is { IsOptional: true })
		{
			definition.HasDefault = true;
			definition.Constant ??= Constant.FromDefault(parameter.ParameterType);
		}
	}
}
```
author_association: CONTRIBUTOR
user: login: siegfriedpammer
created_at: 2025-05-05T06:38:05Z
body: No, this is a bug and I fixed similar things in the past. Feel free to create a PR. Thank you very much!
author_association: MEMBER
user: login: ds5678
created_at: 2025-05-05T07:28:53Z
body: Which is preferred?

```cs
public static void M(int i = 0, DateTime d = default(DateTime))
```

```cs
public static void M([Optional, DefaultParameterValue(0)] int i, [Optional] DateTime d)
```
author_association: CONTRIBUTOR
user: login: greenozon
created_at: 2025-05-05T07:42:14Z
body: Those belongs to diff C# versions

1st one - 
Uses default parameter values introduced in C# 4.0 (2010).
default(DateTime) is allowed since C# 7.1

Aso, you could use type inference feature and write as 
public static void M(int i = 0, DateTime d = default)

2nd one -
Uses attributes from System.Runtime.InteropServices (Optional and DefaultParameterValue).
These attributes are primarily used for COM interop and were available in C# 1.0 (part of .NET from the beginning).
author_association: CONTRIBUTOR
user: login: siegfriedpammer
created_at: 2025-05-05T18:18:24Z
body: see also #2920

@greenozon this is not entirely correct... see https://sharplab.io/#v2:C4LglgNgPgAgTARgLACgYAYAEMEDoBKArgHbBgC2AprgJKmUBOA9gA4DKjAbmAMaUDOAblQwAzNjiYAwpgDeqTIuziYAFkwARSgDMAhoQjAACroa6qwRgAowpTLswBeTAFYAlHIVKAvl8VjsdS09A2NTc0pLBnxdAHcrAG0AeRYyJmJdCAAaTR19QxMzC0YANUzCSit3AF1bYHsPeRQlTF8UbyA=

if you compare the IL for the `DefaultParameter` and `DefaultParameterRaw` methods, you can see that `int x = 5` is actually just syntactic sugar for `[Optional, DefaultParameterValue(5)] int x`.

So, looking at the original problem:

```csharp
public static void M(int i = 0, [Optional] DateTime d) {}
```
leads to `error CS1737: Optional parameters must appear after all required parameters`

but this seems to work and is semantically equivalent:

```csharp
public static void M([Optional, DefaultParameterValue(0)] int i, [Optional] DateTime d) {}
```

So I think, we need some extra validation in the type system that makes sure that all parameters after the first "natively supported" optional parameter are also "natively supported" optional parameters and if that's not the case just don't translate the `Optional` and `DefaultParameterValue` pseudo attributes when decoding the `MetadataParameter` instances.
author_association: MEMBER

Diff:
diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/OptionalArguments.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/OptionalArguments.cs
index d69c8f35aa..a65d7223bb 100644
--- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/OptionalArguments.cs
+++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/OptionalArguments.cs
@@ -16,6 +16,7 @@
 // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 // DEALINGS IN THE SOFTWARE.
 
+using System;
 using System.Collections.Generic;
 using System.Runtime.CompilerServices;
 using System.Runtime.InteropServices;
@@ -307,8 +308,17 @@ public static void Issue2920p(in int x = 3)
 		{
 		}
 #endif
-
+		public static void Issue3469a([Optional][DefaultParameterValue(0)] int i, [Optional] DateTime d)
+		{
+		}
 #if CS120
+		public static Action<int, DateTime> Issue3469b()
+		{
+#pragma warning disable CS9099 // Parameter 1 has default value 'default(int)' in lambda but '<missing>' in the target delegate type
+			return ([Optional][DefaultParameterValue(0)] int i, [Optional] DateTime d) => {
+			};
+#pragma warning restore CS9099
+		}
 		public static D LambdaWithOptionalParameter()
 		{
 			return (int x = 10) => x;
diff --git a/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs b/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs
index 3583c6a44a..119185bc58 100644
--- a/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs
+++ b/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs
@@ -1735,8 +1735,7 @@ public ParameterDeclaration ConvertParameter(IParameter parameter)
 			{
 				decl.Name = parameter.Name;
 			}
-			if (parameter.IsOptional && decl.ParameterModifier is ReferenceKind.None or ReferenceKind.In or ReferenceKind.RefReadOnly
-				&& parameter.HasConstantValueInSignature && this.ShowConstantValues)
+			if (parameter.IsDefaultValueAssignmentAllowed() && this.ShowConstantValues)
 			{
 				try
 				{
diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataParameter.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataParameter.cs
index 14bee08dc5..8089b1eaef 100644
--- a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataParameter.cs
+++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataParameter.cs
@@ -63,14 +63,14 @@ public IEnumerable<IAttribute> GetAttributes()
 			var metadata = module.metadata;
 			var parameter = metadata.GetParameter(handle);
 
-			bool defaultValueAssignmentAllowed = ReferenceKind is ReferenceKind.None or ReferenceKind.In or ReferenceKind.RefReadOnly;
+			bool defaultValueAssignmentAllowed = this.IsDefaultValueAssignmentAllowed();
 
-			if (IsOptional && (!defaultValueAssignmentAllowed || !HasConstantValueInSignature))
+			if (IsOptional && !defaultValueAssignmentAllowed)
 			{
 				b.Add(KnownAttribute.Optional);
 			}
 
-			if (!(IsDecimalConstant || !HasConstantValueInSignature) && (!defaultValueAssignmentAllowed || !IsOptional))
+			if (!IsDecimalConstant && HasConstantValueInSignature && !defaultValueAssignmentAllowed)
 			{
 				b.Add(KnownAttribute.DefaultParameterValue, KnownTypeCode.Object, GetConstantValue(throwOnInvalidMetadata: false));
 			}
diff --git a/ICSharpCode.Decompiler/TypeSystem/TypeSystemExtensions.cs b/ICSharpCode.Decompiler/TypeSystem/TypeSystemExtensions.cs
index e26781cfbf..d4009bb31a 100644
--- a/ICSharpCode.Decompiler/TypeSystem/TypeSystemExtensions.cs
+++ b/ICSharpCode.Decompiler/TypeSystem/TypeSystemExtensions.cs
@@ -612,6 +612,45 @@ public static IAttribute GetAttribute(this IParameter parameter, KnownAttribute
 		}
 		#endregion
 
+		#region IParameter.IsDefaultValueAssignmentAllowed
+		/// <summary>
+		/// Checks if the parameter is allowed to be assigned a default value.
+		/// </summary>
+		/// <remarks>
+		/// This checks <see cref="IParameter.IsOptional"/>, <see cref="IParameter.HasConstantValueInSignature"/>, <see cref="IParameter.ReferenceKind"/>,
+		/// and <see cref="IParameter.IsParams"/> on this parameter and all subsequent parameters.
+		/// If the parameter has no <see cref="IParameter.Owner"/>, it does not check subsequent parameters.
+		/// </remarks>
+		/// <param name="parameter">The parameter</param>
+		/// <returns>True if the <paramref name="parameter"/> has a default value and is allowed to be assigned a default value.</returns>
+		public static bool IsDefaultValueAssignmentAllowed(this IParameter parameter)
+		{
+			if (!DefaultValueAssignmentAllowedIndividual(parameter))
+				return false;
+
+			if (parameter.Owner == null)
+				return true; // Shouldn't happen, but we need to check for it.
+
+			for (int i = parameter.Owner.Parameters.Count - 1; i >= 0; i--)
+			{
+				IParameter otherParameter = parameter.Owner.Parameters[i];
+				if (otherParameter == parameter)
+					break;
+
+				if (DefaultValueAssignmentAllowedIndividual(otherParameter) || otherParameter.IsParams)
+					continue;
+
+				return false;
+			}
+			return true;
+
+			static bool DefaultValueAssignmentAllowedIndividual(IParameter parameter)
+			{
+				return parameter.IsOptional && parameter.HasConstantValueInSignature && parameter.ReferenceKind is ReferenceKind.None or ReferenceKind.In or ReferenceKind.RefReadOnly;
+			}
+		}
+		#endregion
+
 		#region IAssembly.GetTypeDefinition(string,string,int)
 		/// <summary>
 		/// Gets the type definition for a top-level type.


Reviews:
user: login: siegfriedpammer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T19:59:05Z
user: login: siegfriedpammer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T19:59:40Z
user: login: siegfriedpammer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T20:04:10Z
user: login: ds5678
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T20:05:24Z
user: login: ds5678
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T20:09:40Z
user: login: siegfriedpammer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T20:16:46Z
user: login: ds5678
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T20:19:46Z
user: login: siegfriedpammer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T20:20:14Z
user: login: siegfriedpammer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T06:17:24Z
user: login: siegfriedpammer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T06:18:10Z

Review comments:
diff_hunk: @@ -105,4 +105,34 @@ public interface IParameter : IVariable
 		/// </summary>
 		IParameterizedMember? Owner { get; }
 	}
+
+	internal static class IParameterExtensions
+	{
+		public static bool GetDefaultValueAssignmentAllowed(this IParameter parameter)
path: ICSharpCode.Decompiler/TypeSystem/IParameter.cs
user: login: siegfriedpammer
body: IsDefaultValueAssignmentAllowed?
created_at: 2025-05-05T19:59:05Z
author_association: MEMBER
diff_hunk: @@ -105,4 +105,34 @@ public interface IParameter : IVariable
 		/// </summary>
 		IParameterizedMember? Owner { get; }
 	}
+
+	internal static class IParameterExtensions
path: ICSharpCode.Decompiler/TypeSystem/IParameter.cs
user: login: siegfriedpammer
body: We already have `TypeSystemExtensions`, wouldn't it make sense to keep them all in the same unit?
created_at: 2025-05-05T19:59:40Z
author_association: MEMBER
diff_hunk: @@ -105,4 +105,34 @@ public interface IParameter : IVariable
 		/// </summary>
 		IParameterizedMember? Owner { get; }
 	}
+
+	internal static class IParameterExtensions
+	{
+		public static bool GetDefaultValueAssignmentAllowed(this IParameter parameter)
+		{
+			if (!DefaultValueAssignmentAllowedIndividual(parameter))
+				return false;
+
+			if (parameter.Owner == null)
+				return true;
path: ICSharpCode.Decompiler/TypeSystem/IParameter.cs
user: login: siegfriedpammer
body: if the parameter belongs to an anonymous method, we'd still have to figure out whether we can use assignment syntax, I think...

Also I am not sure, whether the `Owner` is actually null in practice, I'd expect the `IParameter` instance that is used for the lambda parameters just to be "owned" by the compiler-generated method from which the lambda was constructed. But I might be wrong.
created_at: 2025-05-05T20:04:10Z
author_association: MEMBER
diff_hunk: @@ -105,4 +105,34 @@ public interface IParameter : IVariable
 		/// </summary>
 		IParameterizedMember? Owner { get; }
 	}
+
+	internal static class IParameterExtensions
path: ICSharpCode.Decompiler/TypeSystem/IParameter.cs
user: login: ds5678
body: I can move it over
created_at: 2025-05-05T20:05:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,4 +105,34 @@ public interface IParameter : IVariable
 		/// </summary>
 		IParameterizedMember? Owner { get; }
 	}
+
+	internal static class IParameterExtensions
+	{
+		public static bool GetDefaultValueAssignmentAllowed(this IParameter parameter)
+		{
+			if (!DefaultValueAssignmentAllowedIndividual(parameter))
+				return false;
+
+			if (parameter.Owner == null)
+				return true;
path: ICSharpCode.Decompiler/TypeSystem/IParameter.cs
user: login: ds5678
body: I can write a unit test?
created_at: 2025-05-05T20:09:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,4 +105,34 @@ public interface IParameter : IVariable
 		/// </summary>
 		IParameterizedMember? Owner { get; }
 	}
+
+	internal static class IParameterExtensions
+	{
+		public static bool GetDefaultValueAssignmentAllowed(this IParameter parameter)
+		{
+			if (!DefaultValueAssignmentAllowedIndividual(parameter))
+				return false;
+
+			if (parameter.Owner == null)
+				return true;
path: ICSharpCode.Decompiler/TypeSystem/IParameter.cs
user: login: siegfriedpammer
body: Yes, you could do that... writing a pretty test that contains a lambda with parameter attributes should be possible in recent versions of C#

Looking at the code that actually creates the anonymous method expressions/lambdas, it seems I remember it correctly: https://github.com/icsharpcode/ILSpy/blob/df1f1051d4fd69595b00fbe829b52826505e4ea6/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs?plain=1#L2406C1-L2406C75

So maybe the documentation needs to be updated :)


created_at: 2025-05-05T20:16:46Z
author_association: MEMBER
diff_hunk: @@ -105,4 +105,34 @@ public interface IParameter : IVariable
 		/// </summary>
 		IParameterizedMember? Owner { get; }
 	}
+
+	internal static class IParameterExtensions
+	{
+		public static bool GetDefaultValueAssignmentAllowed(this IParameter parameter)
+		{
+			if (!DefaultValueAssignmentAllowedIndividual(parameter))
+				return false;
+
+			if (parameter.Owner == null)
+				return true;
path: ICSharpCode.Decompiler/TypeSystem/IParameter.cs
user: login: ds5678
body: > Also I am not sure, whether the `Owner` is actually null in practice, I'd expect the `IParameter` instance that is used for the lambda parameters just to be "owned" by the compiler-generated method from which the lambda was constructed. But I might be wrong.

This intuition was correct.
created_at: 2025-05-05T20:19:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,4 +105,34 @@ public interface IParameter : IVariable
 		/// </summary>
 		IParameterizedMember? Owner { get; }
 	}
+
+	internal static class IParameterExtensions
+	{
+		public static bool GetDefaultValueAssignmentAllowed(this IParameter parameter)
+		{
+			if (!DefaultValueAssignmentAllowedIndividual(parameter))
+				return false;
+
+			if (parameter.Owner == null)
+				return true;
path: ICSharpCode.Decompiler/TypeSystem/IParameter.cs
user: login: siegfriedpammer
body: > So maybe the documentation needs to be updated :)

No, I think, we should keep it that way, otherwise it might break all code that uses `DefaultParameter`s...
created_at: 2025-05-05T20:20:13Z
author_association: MEMBER
diff_hunk: @@ -307,8 +308,15 @@ public static void Issue2920p(in int x = 3)
 		{
 		}
 #endif
-
+		public static void Issue3469a([Optional][DefaultParameterValue(0)] int i, [Optional] DateTime d)
+		{
+		}
 #if CS120
+		public static Action<int, DateTime> Issue3469b()
+		{
path: ICSharpCode.Decompiler.Tests/TestCases/Pretty/OptionalArguments.cs
user: login: siegfriedpammer
body: ```suggestion
		{
#pragma warning disable CS9099 // Parameter 1 has default value 'default(int)' in lambda but '<missing>' in the target delegate type
```
created_at: 2025-05-06T06:17:23Z
author_association: MEMBER
diff_hunk: @@ -307,8 +308,15 @@ public static void Issue2920p(in int x = 3)
 		{
 		}
 #endif
-
+		public static void Issue3469a([Optional][DefaultParameterValue(0)] int i, [Optional] DateTime d)
+		{
+		}
 #if CS120
+		public static Action<int, DateTime> Issue3469b()
+		{
+			return ([Optional][DefaultParameterValue(0)] int i, [Optional] DateTime d) => {
+			};
path: ICSharpCode.Decompiler.Tests/TestCases/Pretty/OptionalArguments.cs
user: login: siegfriedpammer
body: ```suggestion
			};
#pragma warning restore CS9099
```
created_at: 2025-05-06T06:18:10Z
author_association: MEMBER

PR comments:
user: login: ds5678
created_at: 2025-05-17T05:22:31Z
body: Is there anything else that needs done for this pull request?
author_association: CONTRIBUTOR
user: login: siegfriedpammer
created_at: 2025-05-17T15:12:56Z
body: Sorry for the wait... I had second thoughts about the use of an extension method (and then other things came up) instead of implementing this directly in the type system, but it's fine, because this way it's independent from the actual implementation. 
author_association: MEMBER