From 0df949cbae7737489c6b31458ab78db69bf9f219 Mon Sep 17 00:00:00 2001 From: Sebastien Lebreton Date: Sat, 18 Jul 2026 22:59:40 +0200 Subject: [PATCH 1/2] Model scoped parameter lifetimes ScopedRefAttribute only records explicit syntax. Effective lifetime also depends on UnscopedRefAttribute, params collections, out parameters, and the defining module's RefSafetyRules version. Model those distinctions in the type system without changing decompiler output. Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86d2918e-5a24-48b4-9a86-41d331ec3720 --- .../TypeSystem/TypeSystemLoaderTests.cs | 81 +++++++++++++++++++ .../TypeSystem/TypeSystemTestCase.cs | 29 +++++++ .../TypeSystem/IParameter.cs | 43 +++++++++- .../Implementation/KnownAttributes.cs | 9 ++- .../Implementation/MetadataParameter.cs | 30 ++++--- .../TypeSystem/MetadataModule.cs | 38 +++++++++ .../TypeSystem/TypeSystemExtensions.cs | 59 ++++++++++++++ 7 files changed, 275 insertions(+), 14 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/TypeSystem/TypeSystemLoaderTests.cs b/ICSharpCode.Decompiler.Tests/TypeSystem/TypeSystemLoaderTests.cs index e98991a378..1554bb069f 100644 --- a/ICSharpCode.Decompiler.Tests/TypeSystem/TypeSystemLoaderTests.cs +++ b/ICSharpCode.Decompiler.Tests/TypeSystem/TypeSystemLoaderTests.cs @@ -196,6 +196,87 @@ public void DynamicParameterHasNoAttributes() Assert.That(m1.Parameters[0].GetAttributes().Count(), Is.EqualTo(0)); } + [Test] + public void LifetimeAnnotations() + { + ITypeDefinition type = GetTypeDefinition(typeof(LifetimeAnnotationTests)); + + IParameter explicitScopedRef = type.Methods.Single(m => m.Name == "ExplicitScopedRef").Parameters.Single(); + Assert.That(explicitScopedRef.Lifetime.DeclaredScope, Is.EqualTo(ScopedKind.ScopedRef)); + Assert.That(explicitScopedRef.GetEffectiveScope(), Is.EqualTo(ScopedKind.ScopedRef)); + Assert.That(explicitScopedRef.Lifetime.HasUnscopedRefAttribute, Is.False); + Assert.That(explicitScopedRef.Lifetime.ScopedRef, Is.True); + + IParameter explicitScopedValue = type.Methods.Single(m => m.Name == "ExplicitScopedValue").Parameters.Single(); + Assert.That(explicitScopedValue.Lifetime.DeclaredScope, Is.EqualTo(ScopedKind.ScopedValue)); + Assert.That(explicitScopedValue.GetEffectiveScope(), Is.EqualTo(ScopedKind.ScopedValue)); + Assert.That(explicitScopedValue.Lifetime.HasUnscopedRefAttribute, Is.False); + Assert.That(explicitScopedValue.Lifetime.ScopedRef, Is.True); + + IParameter implicitScopedRef = type.Methods.Single(m => m.Name == "ImplicitScopedRef").Parameters.Single(); + Assert.That(implicitScopedRef.Lifetime.DeclaredScope, Is.EqualTo(ScopedKind.None)); + Assert.That(implicitScopedRef.GetEffectiveScope(), Is.EqualTo(ScopedKind.ScopedRef)); + Assert.That(implicitScopedRef.Lifetime.HasUnscopedRefAttribute, Is.False); + Assert.That(implicitScopedRef.Lifetime.ScopedRef, Is.False); + + IParameter unscopedRef = type.Methods.Single(m => m.Name == "UnscopedRef").Parameters.Single(); + Assert.That(unscopedRef.Lifetime.DeclaredScope, Is.EqualTo(ScopedKind.None)); + Assert.That(unscopedRef.GetEffectiveScope(), Is.EqualTo(ScopedKind.None)); + Assert.That(unscopedRef.Lifetime.HasUnscopedRefAttribute, Is.True); + Assert.That(unscopedRef.GetAttributes().Any(a => + a.AttributeType.FullName == "System.Diagnostics.CodeAnalysis.UnscopedRefAttribute"), Is.True); + + var paramsRefStruct = new DefaultParameter(type, "values", isParams: true); + Assert.That(paramsRefStruct.GetEffectiveScope(), Is.EqualTo(ScopedKind.ScopedValue)); + + IMethod instanceMethod = type.Methods.Single(m => m.Name == "InstanceMethod"); + Assert.That(instanceMethod.GetThisParameterScope(), Is.EqualTo(ScopedKind.ScopedRef)); + Assert.That(instanceMethod.HasAttribute(KnownAttribute.UnscopedRef), Is.False); + + IMethod unscopedMethod = type.Methods.Single(m => m.Name == "UnscopedMethod"); + Assert.That(unscopedMethod.GetThisParameterScope(), Is.EqualTo(ScopedKind.None)); + Assert.That(unscopedMethod.HasAttribute(KnownAttribute.UnscopedRef), Is.True); + + IProperty unscopedProperty = type.Properties.Single(p => p.Name == "UnscopedProperty"); + Assert.That(unscopedProperty.Getter.GetThisParameterScope(), Is.EqualTo(ScopedKind.None)); + Assert.That(unscopedProperty.HasAttribute(KnownAttribute.UnscopedRef), Is.True); + + IProperty accessorUnscopedProperty = type.Properties.Single(p => p.Name == "AccessorUnscopedProperty"); + Assert.That(accessorUnscopedProperty.Getter.GetThisParameterScope(), Is.EqualTo(ScopedKind.None)); + Assert.That(accessorUnscopedProperty.Getter.HasAttribute(KnownAttribute.UnscopedRef), Is.True); + + IMethod staticMethod = type.Methods.Single(m => m.Name == "ExplicitScopedRef"); + Assert.That(staticMethod.GetThisParameterScope(), Is.EqualTo(ScopedKind.None)); + + IMethod classMethod = GetTypeDefinition(typeof(SimplePublicClass)).Methods.Single(m => m.Name == "Method"); + Assert.That(classMethod.GetThisParameterScope(), Is.EqualTo(ScopedKind.None)); + + // The legacy mscorlib fixture has no RefSafetyRulesAttribute, so out is not implicitly scoped. + ITypeDefinition int32 = compilation.FindType(KnownTypeCode.Int32).GetDefinition(); + IMethod legacyTryParse = int32.Methods.Single(m => m.Name == "TryParse" + && m.Parameters.Count == 2 + && m.Parameters[1].ReferenceKind == ReferenceKind.Out); + Assert.That(legacyTryParse.Parameters[1].GetEffectiveScope(), Is.EqualTo(ScopedKind.None)); + } + + [Test] + public void LifetimeAnnotationsCanBeDisabled() + { + var compilationWithoutLifetimeAnnotations = new SimpleCompilation( + TestAssembly.WithOptions(TypeSystemOptions.Default & ~TypeSystemOptions.ScopedRef), + Mscorlib.WithOptions(TypeSystemOptions.Default | TypeSystemOptions.OnlyPublicAPI)); + ITypeDefinition type = compilationWithoutLifetimeAnnotations.FindType(typeof(LifetimeAnnotationTests)).GetDefinition(); + + IParameter explicitScopedRef = type.Methods.Single(m => m.Name == "ExplicitScopedRef").Parameters.Single(); + Assert.That(explicitScopedRef.Lifetime.DeclaredScope, Is.EqualTo(ScopedKind.None)); + Assert.That(explicitScopedRef.GetEffectiveScope(), Is.EqualTo(ScopedKind.None)); + Assert.That(explicitScopedRef.GetAttributes().Any(a => + a.AttributeType.FullName == "System.Runtime.CompilerServices.ScopedRefAttribute"), Is.True); + + IMethod instanceMethod = type.Methods.Single(m => m.Name == "InstanceMethod"); + Assert.That(instanceMethod.GetThisParameterScope(), Is.EqualTo(ScopedKind.None)); + } + [Test] public void AssemblyAttribute() { diff --git a/ICSharpCode.Decompiler.Tests/TypeSystem/TypeSystemTestCase.cs b/ICSharpCode.Decompiler.Tests/TypeSystem/TypeSystemTestCase.cs index e3135e43e4..5213cb5084 100644 --- a/ICSharpCode.Decompiler.Tests/TypeSystem/TypeSystemTestCase.cs +++ b/ICSharpCode.Decompiler.Tests/TypeSystem/TypeSystemTestCase.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -219,6 +220,34 @@ public void MethodWithOptionalDecimalParameter(decimal x = 1) { } public void VarArgsMethod(__arglist) { } } + public ref struct LifetimeAnnotationTests + { + private int value; + + public static void ExplicitScopedRef(scoped ref int value) { } + public static void ExplicitScopedValue(scoped LifetimeAnnotationTests value) { } + public static void ImplicitScopedRef(out int value) { value = 0; } + public static void UnscopedRef([UnscopedRef] out int value) { value = 0; } + + public void InstanceMethod() { } + + [UnscopedRef] + public ref int UnscopedMethod() + { + return ref value; + } + + [UnscopedRef] + public ref int UnscopedProperty => ref value; + + public ref int AccessorUnscopedProperty { + [UnscopedRef] + get { + return ref value; + } + } + } + public class VarArgsCtor { public VarArgsCtor(__arglist) { } diff --git a/ICSharpCode.Decompiler/TypeSystem/IParameter.cs b/ICSharpCode.Decompiler/TypeSystem/IParameter.cs index 8ab231679d..441da2e030 100644 --- a/ICSharpCode.Decompiler/TypeSystem/IParameter.cs +++ b/ICSharpCode.Decompiler/TypeSystem/IParameter.cs @@ -36,16 +36,46 @@ public enum ReferenceKind : byte RefReadOnly, } + /// + /// Describes which part of a parameter or local is restricted to the current scope. + /// + public enum ScopedKind : byte + { + None, + ScopedRef, + ScopedValue, + } + public struct LifetimeAnnotation { /// - /// C# 11 scoped annotation: "scoped ref" (ScopedRefAttribute) + /// Gets or sets the scope explicitly encoded by ScopedRefAttribute. + /// Implicit and redundant scopedness is excluded. /// - public bool ScopedRef { + public ScopedKind DeclaredScope { + get { +#pragma warning disable 618 + if (ValueScoped) + return ScopedKind.ScopedValue; + if (RefScoped) + return ScopedKind.ScopedRef; +#pragma warning restore 618 + return ScopedKind.None; + } + set { #pragma warning disable 618 - get { return RefScoped; } - set { RefScoped = value; } + RefScoped = value != ScopedKind.None; + ValueScoped = value == ScopedKind.ScopedValue; #pragma warning restore 618 + } + } + + /// + /// C# 11 scoped annotation: "scoped ref" (ScopedRefAttribute) + /// + public bool ScopedRef { + get { return DeclaredScope != ScopedKind.None; } + set { DeclaredScope = value ? ScopedKind.ScopedRef : ScopedKind.None; } } [Obsolete("Use ScopedRef property instead of directly accessing this field")] @@ -53,6 +83,11 @@ public bool ScopedRef { [Obsolete("C# 11 preview: \"ref scoped\" no longer supported")] public bool ValueScoped; + + /// + /// Gets or sets whether UnscopedRefAttribute is present. + /// + public bool HasUnscopedRefAttribute { get; set; } } public interface IParameter : IVariable diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs index 52a20c3375..d346096331 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs @@ -120,11 +120,15 @@ public enum KnownAttribute // C# 14 attributes: ExtensionMarker, + + // Lifetime attributes: + UnscopedRef, + RefSafetyRules, } public static class KnownAttributes { - internal const int Count = (int)KnownAttribute.ExtensionMarker + 1; + internal const int Count = (int)KnownAttribute.RefSafetyRules + 1; static readonly TopLevelTypeName[] typeNames = new TopLevelTypeName[Count]{ default, @@ -200,6 +204,9 @@ public static class KnownAttributes new TopLevelTypeName("System.Runtime.CompilerServices", "InlineArrayAttribute"), // C# 14 attributes: new TopLevelTypeName("System.Runtime.CompilerServices", "ExtensionMarkerAttribute"), + // Lifetime attributes: + new TopLevelTypeName("System.Diagnostics.CodeAnalysis", "UnscopedRefAttribute"), + new TopLevelTypeName("System.Runtime.CompilerServices", "RefSafetyRulesAttribute"), }; public static ref readonly TopLevelTypeName GetTypeName(this KnownAttribute attr) diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataParameter.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataParameter.cs index c62438cb98..238c9ed2e5 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataParameter.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataParameter.cs @@ -125,17 +125,29 @@ public LifetimeAnnotation Lifetime { var metadata = module.metadata; var parameterDef = metadata.GetParameter(handle); - if ((module.TypeSystemOptions & TypeSystemOptions.ParamsCollections) != 0 - && parameterDef.GetCustomAttributes().HasKnownAttribute(metadata, KnownAttribute.ParamCollection)) + var attributes = parameterDef.GetCustomAttributes(); + bool hasUnscopedRefAttribute = attributes.HasKnownAttribute(metadata, KnownAttribute.UnscopedRef); + bool isParamsCollection = (module.TypeSystemOptions & TypeSystemOptions.ParamsCollections) != 0 + && attributes.HasKnownAttribute(metadata, KnownAttribute.ParamCollection); + ScopedKind declaredScope = ScopedKind.None; + if (!hasUnscopedRefAttribute + && !isParamsCollection + && attributes.HasKnownAttribute(metadata, KnownAttribute.ScopedRef)) { - // params collections are implicitly scoped - return default; - } - if (parameterDef.GetCustomAttributes().HasKnownAttribute(metadata, KnownAttribute.ScopedRef)) - { - return new LifetimeAnnotation { ScopedRef = true }; + if (ReferenceKind != ReferenceKind.None) + { + declaredScope = ScopedKind.ScopedRef; + } + else if (Type.IsRefLikeOrAllowsRefLikeType()) + { + declaredScope = ScopedKind.ScopedValue; + } } - return default; + + return new LifetimeAnnotation { + DeclaredScope = declaredScope, + HasUnscopedRefAttribute = hasUnscopedRefAttribute + }; } } diff --git a/ICSharpCode.Decompiler/TypeSystem/MetadataModule.cs b/ICSharpCode.Decompiler/TypeSystem/MetadataModule.cs index 9701bba081..0002b5d0a0 100644 --- a/ICSharpCode.Decompiler/TypeSystem/MetadataModule.cs +++ b/ICSharpCode.Decompiler/TypeSystem/MetadataModule.cs @@ -91,6 +91,7 @@ internal MetadataModule(ICompilation compilation, MetadataFile peFile, TypeSyste var customAttrs = metadata.GetModuleDefinition().GetCustomAttributes(); this.NullableContext = customAttrs.GetNullableContext(metadata) ?? Nullability.Oblivious; this.minAccessibilityForNRT = FindMinimumAccessibilityForNRT(metadata, customAttrs); + this.UseUpdatedEscapeRules = HasRefSafetyRulesVersion(customAttrs, 11); this.rootNamespace = new MetadataNamespace(this, null, string.Empty, metadata.GetNamespaceDefinitionRoot()); if (!options.HasFlag(TypeSystemOptions.Uncached)) @@ -112,6 +113,43 @@ internal string GetString(StringHandle name) public TypeSystemOptions TypeSystemOptions => options; + /// + /// Gets whether the module declares the C# 11 ref-safety rules. + /// + internal bool UseUpdatedEscapeRules { get; } + + bool HasRefSafetyRulesVersion(CustomAttributeHandleCollection attributes, int expectedVersion) + { + foreach (var attributeHandle in attributes) + { + var attribute = metadata.GetCustomAttribute(attributeHandle); + if (!attribute.IsKnownAttribute(metadata, KnownAttribute.RefSafetyRules)) + continue; + + CustomAttributeValue value; + try + { + value = attribute.DecodeValue(Metadata.MetadataExtensions.MinimalAttributeTypeProvider); + } + catch (BadImageFormatException) + { + continue; + } + catch (Metadata.EnumUnderlyingTypeResolveException) + { + continue; + } + if (value.FixedArguments.Length == 1 + && value.FixedArguments[0].Value is int version) + { + // Roslyn recognizes exactly version 11; missing and unknown versions use legacy rules. + return version == expectedVersion; + } + } + + return false; + } + #region IModule interface public MetadataFile MetadataFile { get; } diff --git a/ICSharpCode.Decompiler/TypeSystem/TypeSystemExtensions.cs b/ICSharpCode.Decompiler/TypeSystem/TypeSystemExtensions.cs index 90bf02ce64..a42b17d2b1 100644 --- a/ICSharpCode.Decompiler/TypeSystem/TypeSystemExtensions.cs +++ b/ICSharpCode.Decompiler/TypeSystem/TypeSystemExtensions.cs @@ -658,6 +658,65 @@ public static IAttribute GetAttribute(this IParameter parameter, KnownAttribute } #endregion + #region Lifetime annotations + internal static bool IsRefLikeOrAllowsRefLikeType(this IType type) + { + return type.IsByRefLike + || type is ITypeParameter { AllowsRefLikeType: true }; + } + + /// + /// Gets the parameter's effective scope after applying implicit scope rules and + /// UnscopedRefAttribute. + /// + internal static ScopedKind GetEffectiveScope(this IParameter parameter) + { + LifetimeAnnotation lifetime = parameter.Lifetime; + if (lifetime.HasUnscopedRefAttribute) + return ScopedKind.None; + + if (lifetime.DeclaredScope != ScopedKind.None) + return lifetime.DeclaredScope; + + if (parameter.Owner?.ParentModule is MetadataModule module) + { + if ((module.TypeSystemOptions & TypeSystemOptions.ScopedRef) == 0) + return ScopedKind.None; + + if (module.UseUpdatedEscapeRules && parameter.ReferenceKind == ReferenceKind.Out) + return ScopedKind.ScopedRef; + } + + if (parameter.IsParams && parameter.Type.IsRefLikeOrAllowsRefLikeType()) + return ScopedKind.ScopedValue; + + return ScopedKind.None; + } + + /// + /// Gets the effective scope of the implicit this parameter. + /// + internal static ScopedKind GetThisParameterScope(this IMethod method) + { + if (method.IsStatic || method.DeclaringTypeDefinition?.Kind != TypeKind.Struct) + return ScopedKind.None; + + if (method.ParentModule is MetadataModule module) + { + if ((module.TypeSystemOptions & TypeSystemOptions.ScopedRef) == 0) + return ScopedKind.None; + + bool hasUnscopedRefAttribute = method.HasAttribute(KnownAttribute.UnscopedRef) + || method.AccessorOwner is IProperty property + && property.HasAttribute(KnownAttribute.UnscopedRef); + if (hasUnscopedRefAttribute && module.UseUpdatedEscapeRules) + return ScopedKind.None; + } + + return ScopedKind.ScopedRef; + } + #endregion + #region IParameter.IsDefaultValueAssignmentAllowed /// /// Checks if the parameter is allowed to be assigned a default value. From 1b4acf4b1709b2d60bf7f7d0bbeeb4eeb877bfda Mon Sep 17 00:00:00 2001 From: Sebastien Lebreton Date: Sun, 19 Jul 2026 18:13:17 +0200 Subject: [PATCH 2/2] Remove obsolete lifetime annotation fields ScopedKind is now the authoritative lifetime representation, so retaining the preview-era boolean fields would duplicate state. Keep the current ScopedRef compatibility property and group the new metadata attributes with the other C# 11 attributes. Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86d2918e-5a24-48b4-9a86-41d331ec3720 --- .../TypeSystem/IParameter.cs | 25 +------------------ .../Implementation/KnownAttributes.cs | 13 ++++------ 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/ICSharpCode.Decompiler/TypeSystem/IParameter.cs b/ICSharpCode.Decompiler/TypeSystem/IParameter.cs index 441da2e030..7937affc4c 100644 --- a/ICSharpCode.Decompiler/TypeSystem/IParameter.cs +++ b/ICSharpCode.Decompiler/TypeSystem/IParameter.cs @@ -18,7 +18,6 @@ #nullable enable -using System; using System.Collections.Generic; using System.Runtime.CompilerServices; @@ -52,23 +51,7 @@ public struct LifetimeAnnotation /// Gets or sets the scope explicitly encoded by ScopedRefAttribute. /// Implicit and redundant scopedness is excluded. /// - public ScopedKind DeclaredScope { - get { -#pragma warning disable 618 - if (ValueScoped) - return ScopedKind.ScopedValue; - if (RefScoped) - return ScopedKind.ScopedRef; -#pragma warning restore 618 - return ScopedKind.None; - } - set { -#pragma warning disable 618 - RefScoped = value != ScopedKind.None; - ValueScoped = value == ScopedKind.ScopedValue; -#pragma warning restore 618 - } - } + public ScopedKind DeclaredScope { get; set; } /// /// C# 11 scoped annotation: "scoped ref" (ScopedRefAttribute) @@ -78,12 +61,6 @@ public bool ScopedRef { set { DeclaredScope = value ? ScopedKind.ScopedRef : ScopedKind.None; } } - [Obsolete("Use ScopedRef property instead of directly accessing this field")] - public bool RefScoped; - - [Obsolete("C# 11 preview: \"ref scoped\" no longer supported")] - public bool ValueScoped; - /// /// Gets or sets whether UnscopedRefAttribute is present. /// diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs index d346096331..5f31844936 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs @@ -114,21 +114,19 @@ public enum KnownAttribute // C# 11 attributes: Required, + UnscopedRef, + RefSafetyRules, // C# 12 attributes: InlineArray, // C# 14 attributes: ExtensionMarker, - - // Lifetime attributes: - UnscopedRef, - RefSafetyRules, } public static class KnownAttributes { - internal const int Count = (int)KnownAttribute.RefSafetyRules + 1; + internal const int Count = (int)KnownAttribute.ExtensionMarker + 1; static readonly TopLevelTypeName[] typeNames = new TopLevelTypeName[Count]{ default, @@ -200,13 +198,12 @@ public static class KnownAttributes new TopLevelTypeName("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute"), // C# 11 attributes: new TopLevelTypeName("System.Runtime.CompilerServices", "RequiredMemberAttribute"), + new TopLevelTypeName("System.Diagnostics.CodeAnalysis", "UnscopedRefAttribute"), + new TopLevelTypeName("System.Runtime.CompilerServices", "RefSafetyRulesAttribute"), // C# 12 attributes: new TopLevelTypeName("System.Runtime.CompilerServices", "InlineArrayAttribute"), // C# 14 attributes: new TopLevelTypeName("System.Runtime.CompilerServices", "ExtensionMarkerAttribute"), - // Lifetime attributes: - new TopLevelTypeName("System.Diagnostics.CodeAnalysis", "UnscopedRefAttribute"), - new TopLevelTypeName("System.Runtime.CompilerServices", "RefSafetyRulesAttribute"), }; public static ref readonly TopLevelTypeName GetTypeName(this KnownAttribute attr)