diff --git a/src/Foundation/NSUrlSessionHandler.cs b/src/Foundation/NSUrlSessionHandler.cs index 64ae91c3b82f..2db105e4eb85 100644 --- a/src/Foundation/NSUrlSessionHandler.cs +++ b/src/Foundation/NSUrlSessionHandler.cs @@ -1036,17 +1036,17 @@ void WillCacheResponseImpl (NSUrlSession session, NSUrlSessionDataTask dataTask, } [Preserve (Conditional = true)] - public override void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler) + public override void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler) { if (!sessionHandler.AllowAutoRedirect) { - completionHandler (null!); + completionHandler (null); return; } var inflight = GetInflightData (task); if (inflight is null) { - completionHandler (null!); + completionHandler (null); return; } diff --git a/src/bgen/AttributeManager.cs b/src/bgen/AttributeManager.cs index 4e3b3acbb927..e925d72027f2 100644 --- a/src/bgen/AttributeManager.cs +++ b/src/bgen/AttributeManager.cs @@ -703,4 +703,44 @@ public bool IsNullable (ICustomAttributeProvider? provider) return false; } + + // Returns the full nullability byte array from a [NullableAttribute], or null if not present. + // The byte array encodes nullability for the type and all its generic type arguments in + // depth-first traversal order: + // 0 = oblivious, 1 = not nullable, 2 = nullable + // For example, Action would have bytes [1, 2, 2]. + public byte []? GetNullabilityBytes (ICustomAttributeProvider? provider) + { + var attributes = GetAttributes (provider); + if (attributes is null) { + return null; + } + + foreach (var attrib in attributes) { + var attribType = attrib.GetAttributeType (); + if (attribType.Name == "NullableAttribute") { + if (attrib.ConstructorArguments.Count == 1) { + var argType = attrib.ConstructorArguments [0].ArgumentType; + if (argType.Namespace == "System" && argType.Name == "Byte") { + if (attrib.ConstructorArguments [0].Value is byte b) { + return new [] { b }; + } + } + if (argType.IsArray && argType.GetElementType ()?.Namespace == "System" && argType.GetElementType ()?.Name == "Byte") { + if (attrib.ConstructorArguments [0].Value is ReadOnlyCollection valueCollection) { + var result = new byte [valueCollection.Count]; + for (int i = 0; i < valueCollection.Count; i++) { + if (valueCollection [i].Value is byte val) { + result [i] = val; + } + } + return result; + } + } + } + } + } + + return null; + } } diff --git a/src/bgen/Generator.cs b/src/bgen/Generator.cs index f1221a33dafb..c3b8ca924f9f 100644 --- a/src/bgen/Generator.cs +++ b/src/bgen/Generator.cs @@ -2927,7 +2927,17 @@ public void MakeSignatureFromParameterInfo (bool comma, StringBuilder sb, Member if (!bt.IsValueType && AttributeManager.IsNullable (pi)) sb.Append ('?'); } else { - sb.Append (TypeManager.FormatType (declaringType, parType)); + // Only apply nullability bytes for void-returning delegate types (Action<> variants). + // Func<> types have a covariant TResult which creates a type mismatch with the + // trampoline's CreateNullableBlock signature. + byte []? nullabilityBytes = null; + if (parType.IsSubclassOf (TypeCache.System_Delegate)) { + var invokeMethod = parType.GetMethod ("Invoke"); + if (invokeMethod is not null && invokeMethod.ReturnType == TypeCache.System_Void) { + nullabilityBytes = AttributeManager.GetNullabilityBytes (pi); + } + } + sb.Append (TypeManager.FormatType (declaringType, parType, nullabilityBytes)); // some `IntPtr` are decorated with `[NullAttribute]` if (!parType.IsValueType) { if (AttributeManager.IsNullable (pi)) { @@ -2935,8 +2945,9 @@ public void MakeSignatureFromParameterInfo (bool comma, StringBuilder sb, Member } else if (pi.Position == 0 && mi is MethodInfo minfo) { // only need to check for setter, since we wouldn't get here for a getter. var propertyInfo = GetProperty (minfo, getter: false, setter: true); - if (AttributeManager.IsNullable (propertyInfo)) + if (AttributeManager.IsNullable (propertyInfo)) { sb.Append ('?'); + } } } } @@ -4029,10 +4040,11 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields print_generated_code (); PrintPropertyAttributes (pi, minfo); PrintAttributes (pi, preserve: true, advice: true); + var wrapNullabilityBytes = AttributeManager.GetNullabilityBytes (pi); print ("{0} {1}{2}{3} {4} {{", mod, minfo.GetModifiers (), - TypeManager.FormatType (pi.DeclaringType, pi.PropertyType), + TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes), nullable ? "?" : String.Empty, pi.Name.GetSafeParamName ()); indent++; @@ -4043,14 +4055,14 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields if (TypeManager.IsDictionaryContainerType (pi.PropertyType)) { print ("var src = {0} is not null ? new NSMutableDictionary ({0}) : null;", wrap); - print ("return src is null ? null! : new {0}(src);", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType)); + print ("return src is null ? null! : new {0}(src);", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes)); } else { if (TypeManager.IsArrayOfWrappedType (pi.PropertyType)) print ("return NSArray.FromArray<{0}>({1} as NSArray){2};", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType.GetElementType ()), wrap, nullable ? "" : "!"); else if (pi.PropertyType.IsValueType) - print ("return ({0}) ({1});", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType), wrap); + print ("return ({0}) ({1});", TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes), wrap); else - print ("return ({0} as {1})!;", wrap, TypeManager.FormatType (pi.DeclaringType, pi.PropertyType)); + print ("return ({0} as {1})!;", wrap, TypeManager.FormatType (pi.DeclaringType, pi.PropertyType, wrapNullabilityBytes)); } indent--; print ("}"); @@ -4115,7 +4127,17 @@ void GenerateProperty (Type type, PropertyInfo pi, List? instance_fields // it remains nullable only if the BindAs type can be null (i.e. a reference type) nullable = !bindAsAttrib.Type.IsValueType && AttributeManager.IsNullable (pi); } else { - propertyTypeName = TypeManager.FormatType (minfo.type, pi.PropertyType); + // Only apply nullability bytes for void-returning delegate types (Action<> variants). + // Func<> types have a covariant TResult which creates a type mismatch with the + // trampoline's CreateNullableBlock signature. + byte []? nullabilityBytes = null; + if (pi.PropertyType.IsSubclassOf (TypeCache.System_Delegate)) { + var invokeMethod = pi.PropertyType.GetMethod ("Invoke"); + if (invokeMethod is not null && invokeMethod.ReturnType == TypeCache.System_Void) { + nullabilityBytes = AttributeManager.GetNullabilityBytes (pi); + } + } + propertyTypeName = TypeManager.FormatType (minfo.type, pi.PropertyType, nullabilityBytes); } print ("{0} {1}{2}{3} {4} {{", @@ -4280,8 +4302,19 @@ string GetReturnType (AsyncMethodInfo minfo) HashSet? reported1077; string GetAsyncTaskType (AsyncMethodInfo minfo) { - if (minfo.IsSingleArgAsync) - return TypeManager.FormatType (minfo.type, minfo.AsyncCompletionParams [0].ParameterType); + if (minfo.IsSingleArgAsync) { + var paramType = minfo.AsyncCompletionParams [0].ParameterType; + var paramBytes = minfo.CompletionParamNullabilityBytes? [0]; + if (paramBytes is not null && !paramType.IsValueType) { + // Format with nullability for inner generic args + var formatted = TypeManager.FormatType (minfo.type, paramType, paramBytes); + // Check byte 0 of the slice for the param's own nullability + if (paramBytes [0] == 2) + formatted += "?"; + return formatted; + } + return TypeManager.FormatType (minfo.type, paramType); + } var attr = AttributeManager.GetOneCustomAttribute (minfo.mi); if (attr.ResultTypeName is not null) diff --git a/src/bgen/Models/AsyncMethodInfo.cs b/src/bgen/Models/AsyncMethodInfo.cs index d57a30c18937..8552f874f8e4 100644 --- a/src/bgen/Models/AsyncMethodInfo.cs +++ b/src/bgen/Models/AsyncMethodInfo.cs @@ -12,6 +12,11 @@ class AsyncMethodInfo : MemberInformation { public bool IsVoidAsync { get; } public bool IsSingleArgAsync { get; } public MethodInfo MethodInfo { get; } + // Nullability bytes for each non-NSError completion parameter's type subtree. + // Each entry is a byte slice starting at that parameter's position in the + // delegate's NullableAttribute array (byte 0 = the param's own nullability). + // Null if nullability info is unavailable. + public byte []? []? CompletionParamNullabilityBytes { get; } public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, MethodInfo mi, Type? categoryExtensionType, bool isExtensionMethod) : base (generator, gather, mi, type, categoryExtensionType, false, isExtensionMethod) @@ -26,14 +31,68 @@ public AsyncMethodInfo (Generator generator, IMemberGatherer gather, Type type, AsyncCompletionParams = cbParams; var lastParam = cbParams.LastOrDefault (); + var outerParam = mi.GetParameters ().Last (); + var genericArgs = lastType.GetGenericArguments (); + var nullabilityBytes = genericArgs.Length > 0 ? generator.AttributeManager.GetNullabilityBytes (outerParam) : null; + if (lastParam is not null && lastParam.ParameterType.Name == "NSError") { HasNSError = true; - IsNSErrorNullable = generator.AttributeManager.IsNullable (lastParam); + // The nullability info for generic type arguments is encoded in the NullableAttribute + // on the outer method parameter (the one with the Action<...> type), not on the + // delegate's Invoke method parameters. Check the nullability bytes to determine if + // the NSError type argument is nullable. + // This only applies to generic delegate types (Action<...>). For non-generic delegates, + // the NullableAttribute on the outer parameter describes the delegate instance, not + // the Invoke parameters. + if (nullabilityBytes is not null && nullabilityBytes.Length == 1) { + // Single-byte (uniform) form: the same byte applies to all positions + IsNSErrorNullable = nullabilityBytes [0] == 2; + } else if (nullabilityBytes is not null && nullabilityBytes.Length > 1) { + // Multi-byte form: walk the type arguments depth-first to find the byte + // index for the last param (NSError). byte[0] is for the Action<> itself. + int byteIndex = 1; + for (int i = 0; i < genericArgs.Length; i++) { + if (i == genericArgs.Length - 1) { + // This is the last generic argument (NSError), which is a reference type + IsNSErrorNullable = byteIndex < nullabilityBytes.Length && nullabilityBytes [byteIndex] == 2; + break; + } + // Advance the byte index past this argument's subtree + byteIndex += CountNullabilityBytes (genericArgs [i]); + } + } else { + IsNSErrorNullable = generator.AttributeManager.IsNullable (lastParam); + } cbParams = cbParams.DropLast (); } IsVoidAsync = cbParams.Length == 0; IsSingleArgAsync = cbParams.Length == 1; + + // Compute nullability byte slices for each non-NSError completion param + if (nullabilityBytes is not null && genericArgs.Length > 0) { + var nonErrorArgCount = HasNSError ? genericArgs.Length - 1 : genericArgs.Length; + if (nonErrorArgCount > 0) { + CompletionParamNullabilityBytes = new byte [nonErrorArgCount] []; + if (nullabilityBytes.Length == 1) { + // Single-byte (uniform): every param gets the same byte + for (int i = 0; i < nonErrorArgCount; i++) + CompletionParamNullabilityBytes [i] = nullabilityBytes; + } else { + // Multi-byte: extract slices for each generic arg + int byteIndex = 1; // skip byte 0 (the Action<> itself) + for (int i = 0; i < nonErrorArgCount; i++) { + int paramByteCount = CountNullabilityBytes (genericArgs [i]); + if (byteIndex + paramByteCount > nullabilityBytes.Length) + break; // malformed or truncated attribute, skip remaining + var slice = new byte [paramByteCount]; + Array.Copy (nullabilityBytes, byteIndex, slice, 0, paramByteCount); + CompletionParamNullabilityBytes [i] = slice; + byteIndex += paramByteCount; + } + } + } + } } public string GetUniqueParamName (string suggestion) @@ -55,4 +114,34 @@ public string GetUniqueParamName (string suggestion) } } + // Counts how many nullability bytes a type subtree consumes in depth-first order. + // Value types consume 0 bytes (unless they are generic and contain reference type args). + // Reference types consume 1 byte for themselves, plus bytes for their generic args/element types. + static int CountNullabilityBytes (Type type) + { + if (type.IsValueType) { + // Value types don't consume a byte themselves, but their generic args might + var vtargs = type.GetGenericArguments (); + int count = 0; + foreach (var arg in vtargs) + count += CountNullabilityBytes (arg); + return count; + } + + // Reference types consume 1 byte for themselves + int bytes = 1; + + if (type.IsArray) { + var elementType = type.GetElementType (); + if (elementType is not null) + bytes += CountNullabilityBytes (elementType); + } else { + var targs = type.GetGenericArguments (); + foreach (var arg in targs) + bytes += CountNullabilityBytes (arg); + } + + return bytes; + } + } diff --git a/src/bgen/TypeManager.cs b/src/bgen/TypeManager.cs index 541d5d436944..6ace113956ad 100644 --- a/src/bgen/TypeManager.cs +++ b/src/bgen/TypeManager.cs @@ -257,6 +257,57 @@ public string FormatType (Type? usedIn, Type? type) return FormatTypeUsedIn (usedIn?.Namespace, type); } + public string FormatType (Type? usedIn, Type? type, byte []? nullabilityBytes) + { + if (type is null) { + throw new BindingException (1065, true); + } + if (nullabilityBytes is null || nullabilityBytes.Length == 0) { + return FormatTypeUsedIn (usedIn?.Namespace, type); + } + + // The byte array encodes nullability for the type tree in depth-first order. + // Byte 0 is for the outer type itself (which the caller handles separately via + // [NullAllowed] / IsNullable), so we skip it. The rest are for generic arguments. + // A single-byte array means uniform nullability for all positions. + var targs = type.GetGenericArguments (); + if (targs.Length == 0 && !type.IsArray) { + return FormatTypeUsedIn (usedIn?.Namespace, type); + } + + // Start index: for single-byte (uniform), don't skip byte 0 since it applies everywhere. + // For multi-byte, skip byte 0 (the outer type, handled by the caller via [NullAllowed]). + int index = nullabilityBytes.Length == 1 ? 0 : 1; + + // For array types, format the element type with nullability. The array's own + // nullability (byte 0) is handled by the caller via [NullAllowed], so we only + // need to format the element type starting at index (which is past byte 0). + if (type.IsArray) { + var elementFormatted = FormatTypeUsedIn (usedIn?.Namespace, type.GetElementType (), nullabilityBytes, ref index); + return elementFormatted + "[" + new string (',', type.GetArrayRank () - 1) + "]"; + } + var formattedArgs = new string [targs.Length]; + for (int i = 0; i < targs.Length; i++) { + formattedArgs [i] = FormatTypeUsedIn (usedIn?.Namespace, targs [i], nullabilityBytes, ref index); + } + + // Get the outer type name without generic args + var usedInNamespace = usedIn?.Namespace; + string tname; + var parentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) { + tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + } else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || + string.IsNullOrEmpty (type.FullName)) { + tname = type.Name; + } else { + tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + } + + return tname.RemoveArity () + "<" + string.Join (", ", formattedArgs) + ">"; + } + public string FormatType (Type? usedIn, string? @namespace, string name) { string tname; @@ -344,6 +395,155 @@ public string FormatTypeUsedIn (string? usedInNamespace, Type? type) return tname; } + // Overload that consumes nullability bytes in depth-first order. + // The byte array from [NullableAttribute] encodes nullability for each type position: + // 0 = oblivious, 1 = not nullable, 2 = nullable + // Value types do NOT consume bytes from the array (their nullability is structural). + // The index tracks our position as we traverse the type tree depth-first. + string FormatTypeUsedIn (string? usedInNamespace, Type? type, byte [] nullabilityBytes, ref int index) + { + if (type is null) { + throw new BindingException (1065, true); + } + + // Value types don't have bytes in the NullableAttribute array. + // Their nullability is determined structurally (by being Nullable). + if (type.IsValueType) { + if (type == TypeCache.System_SByte) { + return "sbyte"; + } + if (type == TypeCache.System_Int32) { + return "int"; + } + if (type == TypeCache.System_Int16) { + return "short"; + } + if (type == TypeCache.System_Int64) { + return "long"; + } + if (type == TypeCache.System_Byte) { + return "byte"; + } + if (type == TypeCache.System_UInt16) { + return "ushort"; + } + if (type == TypeCache.System_UInt32) { + return "uint"; + } + if (type == TypeCache.System_UInt64) { + return "ulong"; + } + if (type == TypeCache.System_Float) { + return "float"; + } + if (type == TypeCache.System_Double) { + return "double"; + } + if (type == TypeCache.System_Boolean) { + return "bool"; + } + if (type == TypeCache.System_nfloat) { + return "nfloat"; + } + if (type == TypeCache.System_nint) { + return "nint"; + } + if (type == TypeCache.System_nuint) { + return "nuint"; + } + if (type == TypeCache.System_Char) { + return "char"; + } + + // Generic value types (e.g. Nullable, KeyValuePair) + var vtargs = type.GetGenericArguments (); + if (vtargs.Length > 0) { + var isNullableValueType = GetUnderlyingNullableType (type) is not null; + if (isNullableValueType) { + return FormatTypeUsedIn (usedInNamespace, vtargs [0], nullabilityBytes, ref index) + "?"; + } + + // Non-nullable generic value type: recurse into its args + var formattedVtArgs = new string [vtargs.Length]; + for (int i = 0; i < vtargs.Length; i++) { + formattedVtArgs [i] = FormatTypeUsedIn (usedInNamespace, vtargs [i], nullabilityBytes, ref index); + } + + string vtname; + var vtParentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) { + vtname = $"global::{type.Namespace}.{vtParentClass}{type.Name}"; + } else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || + string.IsNullOrEmpty (type.FullName)) { + vtname = type.Name; + } else { + vtname = $"global::{type.Namespace}.{vtParentClass}{type.Name}"; + } + return vtname.RemoveArity () + "<" + string.Join (", ", formattedVtArgs) + ">"; + } + + // Non-generic value type: use the standard name + string vname; + var vParentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) { + vname = $"global::{type.Namespace}.{vParentClass}{type.Name}"; + } else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || + string.IsNullOrEmpty (type.FullName)) { + vname = type.Name; + } else { + vname = $"global::{type.Namespace}.{vParentClass}{type.Name}"; + } + return vname; + } + + // Reference types consume one byte from the array. + // For single-byte (uniform) arrays, the same byte applies to all positions + // without incrementing the index. + byte currentByte; + if (nullabilityBytes.Length == 1) { + currentByte = nullabilityBytes [0]; + } else { + currentByte = index < nullabilityBytes.Length ? nullabilityBytes [index] : (byte) 0; + index++; + } + + bool isCurrentNullable = currentByte == 2; + + // Simple reference types + if (type == TypeCache.System_String) { + return "string" + (isCurrentNullable ? "?" : ""); + } + + if (type.IsArray) { + return FormatTypeUsedIn (usedInNamespace, type.GetElementType (), nullabilityBytes, ref index) + "[" + new string (',', type.GetArrayRank () - 1) + "]" + (isCurrentNullable ? "?" : ""); + } + + string tname; + var parentClass = (type.ReflectedType is null) ? String.Empty : type.ReflectedType.Name + "."; + if (typesThatMustAlwaysBeGloballyNamed.Contains (type.Name)) { + tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + } else if ((usedInNamespace is not null && type.Namespace == usedInNamespace) || + BindingTouch.NamespaceCache.StandardNamespaces.Contains (type.Namespace ?? String.Empty) || + string.IsNullOrEmpty (type.FullName)) { + tname = type.Name; + } else { + tname = $"global::{type.Namespace}.{parentClass}{type.Name}"; + } + + var targs = type.GetGenericArguments (); + if (targs.Length > 0) { + var formattedArgs = new string [targs.Length]; + for (int i = 0; i < targs.Length; i++) { + formattedArgs [i] = FormatTypeUsedIn (usedInNamespace, targs [i], nullabilityBytes, ref index); + } + return tname.RemoveArity () + "<" + string.Join (", ", formattedArgs) + ">" + (isCurrentNullable ? "?" : ""); + } + + return tname + (isCurrentNullable ? "?" : ""); + } + public string RenderType (Type t, ICustomAttributeProvider? provider = null) { var nullable = string.Empty; diff --git a/src/foundation.cs b/src/foundation.cs index 08c913182f56..7a850d524575 100644 --- a/src/foundation.cs +++ b/src/foundation.cs @@ -11115,7 +11115,9 @@ partial interface NSUrlSessionTaskDelegate { /// To be added. /// To be added. [Export ("URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler:")] - void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler); +#nullable enable + void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler); +#nullable disable /// To be added. /// To be added. diff --git a/tests/bgen/BGenTests.cs b/tests/bgen/BGenTests.cs index a1b1b7bd3fe6..7ae378daa32a 100644 --- a/tests/bgen/BGenTests.cs +++ b/tests/bgen/BGenTests.cs @@ -1634,6 +1634,79 @@ public void DelegatesWithNullableReturnType (Profile profile) Assert.That (delegateCallback.MethodReturnType.CustomAttributes.Any (v => v.AttributeType.Name == "NullableAttribute"), "Nullable return type"); } + [Test] + [TestCase (Profile.iOS)] + public void GenericTypeNullability (Profile profile) + { + Configuration.IgnoreIfIgnoredPlatform (profile.AsPlatform ()); + var bgen = BuildFile (profile, "generic-type-nullability.cs"); + bgen.AssertNoWarnings (); + + // Find the generated source file and check the property signatures + var generatedFile = Path.Combine (bgen.TmpDirectory!, "NS", "Widget.g.cs"); + Assert.That (File.Exists (generatedFile), Is.True, "Generated file exists"); + var contents = File.ReadAllText (generatedFile); + + // Basic: two nullable generic args + Assert.That (contents, Does.Contain ("Action?"), "AuthenticateHandler should have nullable generic args"); + // Three nullable generic args + Assert.That (contents, Does.Contain ("Action?"), "CompletionHandler should have nullable generic args"); + // Non-nullable generic args should NOT have ? + Assert.That (contents, Does.Contain ("Action?"), "NonNullableHandler should NOT have nullable generic args"); + + // Value type between nullable reference types (int should never get ?) + Assert.That (contents, Does.Contain ("Action?"), "WithValueType should not annotate value types"); + + // Four nullable reference type args + Assert.That (contents, Does.Contain ("Action?"), "ManyNullableArgs should handle 4 nullable args"); + + // Mixed: first and last non-nullable, middle nullable + Assert.That (contents, Does.Contain ("Action?"), "MixedMiddleNullable should only annotate the middle arg"); + + // Multiple value types (int, bool should never get ?) + Assert.That (contents, Does.Contain ("Action?"), "MultipleValueTypes should not annotate any value types"); + + // Alternating nullable/non-nullable pattern + Assert.That (contents, Does.Contain ("Action?"), "AlternatingNullability should preserve alternating pattern"); + + // All non-nullable (5 reference type args, none should get ?) + Assert.That (contents, Does.Contain ("Action?"), "AllNonNullable should not annotate any args"); + + // Value type at the end + Assert.That (contents, Does.Contain ("Action?"), "ValueTypeAtEnd should not annotate trailing value type"); + + // === Method parameter assertions === + + // Method with nullable Action parameter + Assert.That (contents, Does.Contain ("Action"), "DoSomething should have nullable generic arg in method parameter"); + + // Method with mixed nullable/non-nullable Action parameter + Assert.That (contents, Does.Contain ("Action"), "DoSomethingElse should have mixed nullability in method parameter"); + + // Async method: completion handler with nullable NSError should generate Tuple + Assert.That (contents, Does.Contain ("Action"), "ConfirmAcquired should have nullable NSError in method parameter"); + Assert.That (contents, Does.Contain ("Tuple"), "ConfirmAcquired async should generate Tuple with nullable NSError"); + + // Async method: completion handler with non-nullable NSError should generate Tuple + Assert.That (contents, Does.Contain ("Action"), "ConfirmAcquiredNonNull should have non-nullable NSError in method parameter"); + Assert.That (contents, Does.Contain ("Tuple"), "ConfirmAcquiredNonNull async should generate Tuple with non-nullable NSError"); + + // Async method with array arg before NSError (depth-first byte counting) + Assert.That (contents, Does.Contain ("Action"), "FetchItems should have nullable array and NSError"); + // When NSError is nullable, async uses Task with error→exception; the result type preserves nullability + Assert.That (contents, Does.Contain ("Task"), "FetchItems async should return Task (array nullability preserved)"); + + // Async method with nullable result type + Assert.That (contents, Does.Contain ("Task"), "LoadData async should return Task"); + // Async method with non-nullable result type + Assert.That (contents, Does.Match (@"Task\s"), "LoadDataNonNull async should return Task"); + + // Async method with nullable array result type + Assert.That (contents, Does.Contain ("Task"), "LoadItems async should return Task"); + // Async method with non-nullable array result type + Assert.That (contents, Does.Match (@"Task\s"), "LoadItemsNonNull async should return Task"); + } + [Test] [TestCase (Profile.iOS)] public void DelegatesWithPointerTypes (Profile profile) diff --git a/tests/bgen/tests/generic-type-nullability.cs b/tests/bgen/tests/generic-type-nullability.cs new file mode 100644 index 000000000000..7e626563e194 --- /dev/null +++ b/tests/bgen/tests/generic-type-nullability.cs @@ -0,0 +1,112 @@ +using System; + +using Foundation; +using ObjCRuntime; +#if IOS +using UIKit; +#endif + +#nullable enable + +namespace NS { + [BaseType (typeof (NSObject))] + interface Widget { + // === Properties === + + // Basic: two nullable generic args + [Export ("authenticateHandler")] + [NullAllowed] + Action AuthenticateHandler { get; set; } + + // Three nullable generic args + [Export ("completionHandler")] + [NullAllowed] + Action CompletionHandler { get; set; } + + // Non-nullable generic args (should NOT get ?) + [Export ("nonNullableHandler")] + [NullAllowed] + Action NonNullableHandler { get; set; } + + // Value type argument between nullable reference types + [Export ("withValueType")] + [NullAllowed] + Action WithValueType { get; set; } + + // Four nullable reference type args + [Export ("manyNullableArgs")] + [NullAllowed] + Action ManyNullableArgs { get; set; } + + // Mixed: first and last non-nullable, middle nullable + [Export ("mixedMiddleNullable")] + [NullAllowed] + Action MixedMiddleNullable { get; set; } + + // Multiple value types interleaved with nullable reference types + [Export ("multipleValueTypes")] + [NullAllowed] + Action MultipleValueTypes { get; set; } + + // Five args with alternating nullable/non-nullable + [Export ("alternatingNullability")] + [NullAllowed] + Action AlternatingNullability { get; set; } + + // All non-nullable reference types (5 args) + [Export ("allNonNullable")] + [NullAllowed] + Action AllNonNullable { get; set; } + + // Value type at the end + [Export ("valueTypeAtEnd")] + [NullAllowed] + Action ValueTypeAtEnd { get; set; } + + // === Methods with nullable generic type arguments === + + // Method with nullable Action parameter + [Export ("doSomething:completionHandler:")] + void DoSomething (NSObject obj, Action completionHandler); + + // Method with mixed nullable/non-nullable Action parameter + [Export ("doSomethingElse:completionHandler:")] + void DoSomethingElse (NSObject obj, Action completionHandler); + + // Async method with nullable NSError in completion handler + [Async] + [Export ("confirmAcquired:completionHandler:")] + void ConfirmAcquired (NSObject obj, Action completionHandler); + + // Async method with non-nullable NSError in completion handler + [Async] + [Export ("confirmAcquiredNonNull:completionHandler:")] + void ConfirmAcquiredNonNull (NSObject obj, Action completionHandler); + + // Async method with array arg before NSError (tests depth-first byte counting: + // the array type consumes 2 bytes — one for the array itself and one for the element type) + [Async] + [Export ("fetchItems:completionHandler:")] + void FetchItems (NSObject obj, Action completionHandler); + + // Async method with nullable result type (single arg, NSError triggers exception pattern) + [Async] + [Export ("loadData:completionHandler:")] + void LoadData (NSObject obj, Action completionHandler); + + // Async method with non-nullable result type + [Async] + [Export ("loadDataNonNull:completionHandler:")] + void LoadDataNonNull (NSObject obj, Action completionHandler); + + // Async method with nullable array result type + [Async] + [Export ("loadItems:completionHandler:")] + void LoadItems (NSObject obj, Action completionHandler); + + // Async method with non-nullable array result type + [Async] + [Export ("loadItemsNonNull:completionHandler:")] + void LoadItemsNonNull (NSObject obj, Action completionHandler); + } +} diff --git a/tools/api-tools/mono-api-html/ApiChange.cs b/tools/api-tools/mono-api-html/ApiChange.cs index 18cc90285b5c..2b48be6428dd 100644 --- a/tools/api-tools/mono-api-html/ApiChange.cs +++ b/tools/api-tools/mono-api-html/ApiChange.cs @@ -110,11 +110,12 @@ static bool IsNullabilitySuffix (string text, int index) { // A '?' is a nullability suffix if it's at the end, or before a type separator. // The input type names are already formatted (via GetTypeName + Formatter), so generic - // brackets appear as HTML entities (< / >). A '?' before '&' catches the > case. + // brackets appear as HTML entities (< / >) or placeholders (%GREATERTHANREPLACEMENT%). + // A '?' before '&' catches >, and '%' catches %GREATERTHANREPLACEMENT%. if (index + 1 >= text.Length) return true; char next = text [index + 1]; - return next == ']' || next == ',' || next == '>' || next == '&' || next == ' '; + return next == ']' || next == ',' || next == '>' || next == '&' || next == '%' || next == ' '; } } diff --git a/tools/api-tools/mono-api-html/Helpers.cs b/tools/api-tools/mono-api-html/Helpers.cs index 9a98857a0826..d6b786d725e6 100644 --- a/tools/api-tools/mono-api-html/Helpers.cs +++ b/tools/api-tools/mono-api-html/Helpers.cs @@ -262,11 +262,12 @@ public static FieldAttributes GetFieldAttributes (this XElement element) if (type is null) return null; // Remove all '?' that appear before ']', at end of string, before ',', - // before '>' or '&' (HTML entities like >), or before ' ' (before param name) + // before '>' or '&' (HTML entities like >), before ' ' (before param name), + // or before '%' (placeholder boundaries like %GREATERTHANREPLACEMENT%) var sb = new StringBuilder (type.Length); for (int i = 0; i < type.Length; i++) { if (type [i] == '?') { - if (i + 1 >= type.Length || type [i + 1] == ']' || type [i + 1] == ',' || type [i + 1] == '>' || type [i + 1] == '&' || type [i + 1] == ' ') + if (i + 1 >= type.Length || type [i + 1] == ']' || type [i + 1] == ',' || type [i + 1] == '>' || type [i + 1] == '&' || type [i + 1] == ' ' || type [i + 1] == '%') continue; } sb.Append (type [i]); diff --git a/tools/api-tools/mono-api-html/MarkdownFormatter.cs b/tools/api-tools/mono-api-html/MarkdownFormatter.cs index 6f93beed2d14..ab2d7d6a9ac9 100644 --- a/tools/api-tools/mono-api-html/MarkdownFormatter.cs +++ b/tools/api-tools/mono-api-html/MarkdownFormatter.cs @@ -206,10 +206,13 @@ public override void DiffAddition (TextChunk chunk, string text) public override void DiffModification (TextChunk chunk, string old, string @new) { + // The 'old' text is what's being removed (wrap in ---), and 'new' is + // what's being added (wrap in +++). The original code incorrectly called + // DiffAddition for 'old' and DiffRemoval for 'new'. if (old is not null && old.Length > 0) - DiffAddition (chunk, old); + DiffRemoval (chunk, old); if (@new is not null && @new.Length > 0) - DiffRemoval (chunk, @new); + DiffAddition (chunk, @new); } public override void DiffRemoval (TextChunk chunk, string text) @@ -223,9 +226,13 @@ public override void DiffRemoval (TextChunk chunk, string text) public override void Diff (ApiChange apichange) { foreach (var line in apichange.Member.GetStringBuilder (this).ToString ().Split (new [] { Environment.NewLine }, 0)) { - if (line.Contains ("+++")) { - output.WriteLine ("-{0}", Clean (line, "+++", "---")); - output.WriteLine ("+{0}", Clean (line, "---", "+++")); + if (line.Contains ("+++") || line.Contains ("---")) { + var removed = Clean (line, "---", "+++"); + var added = Clean (line, "+++", "---"); + if (!string.IsNullOrWhiteSpace (removed)) + output.WriteLine ("-{0}", removed); + if (!string.IsNullOrWhiteSpace (added)) + output.WriteLine ("+{0}", added); } else { output.WriteLine (" {0}", line); } diff --git a/tools/api-tools/mono-api-html/MultiplexedFormatter.cs b/tools/api-tools/mono-api-html/MultiplexedFormatter.cs index d9327619c222..cc8556c93424 100644 --- a/tools/api-tools/mono-api-html/MultiplexedFormatter.cs +++ b/tools/api-tools/mono-api-html/MultiplexedFormatter.cs @@ -190,8 +190,12 @@ public override void DiffRemoval (TextChunk chunk, string text) public override void Diff (ApiChange apichange) { - foreach (var formatter in formatters) + foreach (var formatter in formatters) { + var sb = apichange.Member.GetStringBuilder (formatter); + sb.Replace (LesserThan, formatter.LesserThan); + sb.Replace (GreaterThan, formatter.GreaterThan); formatter.Diff (apichange); + } } public override void PushOutput () diff --git a/tools/api-tools/mono-api-info/mono-api-info.cs b/tools/api-tools/mono-api-info/mono-api-info.cs index 1b22201ef597..7a3583b11b50 100644 --- a/tools/api-tools/mono-api-info/mono-api-info.cs +++ b/tools/api-tools/mono-api-info/mono-api-info.cs @@ -1014,8 +1014,7 @@ protected override void AddExtraAttributes (MemberReference memberDefinition) base.AddExtraAttributes (memberDefinition); FieldDefinition field = (FieldDefinition) memberDefinition; - var fieldTypeName = Utils.CleanupTypeName (field.FieldType); - fieldTypeName = NullabilityHelper.AppendNullabilityToTypeName (fieldTypeName, field.FieldType, field, field.DeclaringType); + var fieldTypeName = NullabilityHelper.FormatTypeNameWithFullNullability (field.FieldType, field, field.DeclaringType); AddAttribute ("fieldtype", fieldTypeName); if (field.IsLiteral) { @@ -1085,8 +1084,7 @@ protected override void AddExtraAttributes (MemberReference memberDefinition) base.AddExtraAttributes (memberDefinition); PropertyDefinition prop = (PropertyDefinition) memberDefinition; - var ptypeName = Utils.CleanupTypeName (prop.PropertyType); - ptypeName = NullabilityHelper.AppendNullabilityToTypeName (ptypeName, prop.PropertyType, prop, prop.DeclaringType); + var ptypeName = NullabilityHelper.FormatTypeNameWithFullNullability (prop.PropertyType, prop, prop.DeclaringType); AddAttribute ("ptype", ptypeName); bool haveParameters; @@ -1153,8 +1151,7 @@ protected override void AddExtraAttributes (MemberReference memberDefinition) base.AddExtraAttributes (memberDefinition); EventDefinition evt = (EventDefinition) memberDefinition; - var evtTypeName = Utils.CleanupTypeName (evt.EventType); - evtTypeName = NullabilityHelper.AppendNullabilityToTypeName (evtTypeName, evt.EventType, evt, evt.DeclaringType); + var evtTypeName = NullabilityHelper.FormatTypeNameWithFullNullability (evt.EventType, evt, evt.DeclaringType); AddAttribute ("eventtype", evtTypeName); } @@ -1222,10 +1219,9 @@ protected override void AddExtraAttributes (MemberReference memberDefinition) // base method can come from another assembly. AddAttribute ("is-override", "true"); } - string rettype = Utils.CleanupTypeName (mbase.MethodReturnType.ReturnType); + string rettype = NullabilityHelper.FormatTypeNameWithFullNullability (mbase.MethodReturnType.ReturnType, mbase.MethodReturnType, mbase); if (rettype != "System.Void" || !mbase.IsConstructor) { - rettype = NullabilityHelper.AppendNullabilityToTypeName (rettype, mbase.MethodReturnType.ReturnType, mbase.MethodReturnType, mbase); - AddAttribute ("returntype", (rettype)); + AddAttribute ("returntype", rettype); } // // if (mbase.MethodReturnType.HasCustomAttributes) @@ -1310,7 +1306,7 @@ public override void DoOutput () pt = brt.ElementType; } - AddAttribute ("type", NullabilityHelper.AppendNullabilityToTypeName (Utils.CleanupTypeName (pt), pt, parameter, parameter.Method as ICustomAttributeProvider)); + AddAttribute ("type", NullabilityHelper.FormatTypeNameWithFullNullability (pt, parameter, parameter.Method as ICustomAttributeProvider)); if (parameter.IsOptional) { AddAttribute ("optional", "true"); @@ -1413,6 +1409,10 @@ bool SkipAttribute (CustomAttribute attribute) switch (attribute.AttributeType.FullName) { case "System.Runtime.CompilerServices.NativeIntegerAttribute": return false; + case "System.Runtime.CompilerServices.NullableAttribute": + case "System.Runtime.CompilerServices.NullableContextAttribute": + // Nullability is already rendered via '?' annotations on type names. + return true; } if (!state.TypeHelper.IsPublic (attribute)) @@ -1468,25 +1468,6 @@ static class NullabilityHelper { const string NullableAttributeName = "System.Runtime.CompilerServices.NullableAttribute"; const string NullableContextAttributeName = "System.Runtime.CompilerServices.NullableContextAttribute"; - // Returns the nullability flag for the top-level type: - // 0 = oblivious, 1 = not-null, 2 = nullable - public static byte GetTopLevelNullability (ICustomAttributeProvider provider, ICustomAttributeProvider? context) - { - // Check for NullableAttribute directly on the member/parameter/return type - var flag = GetNullableFlagFromProvider (provider); - if (flag.HasValue) - return flag.Value; - - // Fall back to NullableContextAttribute on the containing method/type - if (context is not null) { - var contextFlag = GetNullableContextFlag (context); - if (contextFlag.HasValue) - return contextFlag.Value; - } - - return 0; // oblivious - } - // Gets the NullableContextAttribute flag from a method or type public static byte? GetNullableContextFlag (ICustomAttributeProvider provider) { @@ -1539,7 +1520,9 @@ public static byte GetTopLevelNullability (ICustomAttributeProvider provider, IC return null; } - static byte? GetNullableFlagFromProvider (ICustomAttributeProvider provider) + // Gets the full NullableAttribute byte array from a provider. + // Returns null if no NullableAttribute is present. + static byte []? GetNullabilityBytesFromProvider (ICustomAttributeProvider provider) { if (!provider.HasCustomAttributes) return null; @@ -1552,39 +1535,122 @@ public static byte GetTopLevelNullability (ICustomAttributeProvider provider, IC var arg = attr.ConstructorArguments [0]; if (arg.Value is byte b) - return b; - if (arg.Value is CustomAttributeArgument [] arr && arr.Length > 0 && arr [0].Value is byte b2) - return b2; + return new byte [] { b }; + if (arg.Value is CustomAttributeArgument [] arr) { + var result = new byte [arr.Length]; + for (int i = 0; i < arr.Length; i++) { + if (arr [i].Value is byte val) { + result [i] = val; + } + } + return result; + } } return null; } - public static bool IsNullableReferenceType (TypeReference type, ICustomAttributeProvider provider, ICustomAttributeProvider? context) + // Gets the full nullability byte array for a provider, falling back to NullableContext. + static byte []? GetFullNullabilityBytes (ICustomAttributeProvider provider, ICustomAttributeProvider? context) { - if (type is null) - return false; + var bytes = GetNullabilityBytesFromProvider (provider); + if (bytes is not null) + return bytes; - // Value types use Nullable for nullability, not annotations - if (type.IsValueType) - return false; + if (context is not null) { + var contextFlag = GetNullableContextFlag (context); + if (contextFlag.HasValue) + return new byte [] { contextFlag.Value }; + } + + return null; + } + + // Formats a type name with full nullability annotations, including generic type arguments. + // This reads the NullableAttribute byte array and applies '?' at every position in + // the type tree (depth-first), not just the top-level type. + public static string FormatTypeNameWithFullNullability (TypeReference type, ICustomAttributeProvider provider, ICustomAttributeProvider? context) + { + var bytes = GetFullNullabilityBytes (provider, context); + if (bytes is null) + return Utils.CleanupTypeName (type); + + int index = 0; + return FormatTypeWithNullabilityBytes (type, bytes, ref index); + } - // ByReference types (ref/out parameters): check the element type - if (type.IsByReference) { - var elementType = ((ByReferenceType) type).ElementType; - if (elementType.IsValueType) - return false; + // Recursively formats a type name, consuming nullability bytes in depth-first order. + // Value types don't consume bytes. Reference types consume one byte each. + // A single-byte array means uniform nullability for all positions. + static string FormatTypeWithNullabilityBytes (TypeReference type, byte [] bytes, ref int index) + { + // Void is never nullable + if (type.FullName == "System.Void") + return "System.Void"; + + // Value types don't consume bytes from the NullableAttribute array. + // Their nullability is structural (Nullable). + if (type.IsValueType) { + if (type is GenericInstanceType valueGenType) { + // Handle Nullable + if (valueGenType.ElementType.FullName == "System.Nullable`1") { + return FormatTypeWithNullabilityBytes (valueGenType.GenericArguments [0], bytes, ref index) + "?"; + } + // Other generic value types: recurse into args + var sb = new StringBuilder (); + sb.Append (CleanForNullability (valueGenType.ElementType.FullName)); + sb.Append ('['); + for (int i = 0; i < valueGenType.GenericArguments.Count; i++) { + if (i > 0) + sb.Append (", "); + sb.Append (FormatTypeWithNullabilityBytes (valueGenType.GenericArguments [i], bytes, ref index)); + } + sb.Append (']'); + return sb.ToString (); + } + return Utils.CleanupTypeName (type); + } + + // Reference types consume one byte + byte currentByte; + if (bytes.Length == 1) { + // Single byte: uniform nullability for all positions + currentByte = bytes [0]; + } else { + currentByte = index < bytes.Length ? bytes [index] : (byte) 0; + index++; + } + bool isNullable = currentByte == 2; + + if (type is ArrayType arrType) { + string ranks = new string (',', arrType.Rank - 1); + return FormatTypeWithNullabilityBytes (arrType.ElementType, bytes, ref index) + "[" + ranks + "]" + (isNullable ? "?" : ""); + } + + if (type is GenericInstanceType genType) { + var sb = new StringBuilder (); + sb.Append (CleanForNullability (genType.ElementType.FullName)); + sb.Append ('['); + for (int i = 0; i < genType.GenericArguments.Count; i++) { + if (i > 0) + sb.Append (", "); + sb.Append (FormatTypeWithNullabilityBytes (genType.GenericArguments [i], bytes, ref index)); + } + sb.Append (']'); + if (isNullable) + sb.Append ('?'); + return sb.ToString (); } - var flag = GetTopLevelNullability (provider, context); - return flag == 2; + return Utils.CleanupTypeName (type) + (isNullable ? "?" : ""); } - public static string AppendNullabilityToTypeName (string typeName, TypeReference type, ICustomAttributeProvider provider, ICustomAttributeProvider? context) + // Converts / to + for nested types (same as CleanupTypeName but without <> replacement, + // since we build the generic argument list ourselves with nullability annotations). + // The generic arity suffix (e.g., `2) is preserved because mono-api-html uses it to detect generics. + static string CleanForNullability (string name) { - if (IsNullableReferenceType (type, provider, context)) - return typeName + "?"; - return typeName; + return name.Replace ('/', '+'); } }