diff --git a/src/Xamarin.Android.Tools.Bytecode/AttributeInfo.cs b/src/Xamarin.Android.Tools.Bytecode/AttributeInfo.cs index 85a8387b8..a1a819b65 100644 --- a/src/Xamarin.Android.Tools.Bytecode/AttributeInfo.cs +++ b/src/Xamarin.Android.Tools.Bytecode/AttributeInfo.cs @@ -51,7 +51,10 @@ public class AttributeInfo { public const string StackMapTable = "StackMapTable"; public const string RuntimeVisibleAnnotations = "RuntimeVisibleAnnotations"; public const string RuntimeInvisibleAnnotations = "RuntimeInvisibleAnnotations"; + public const string RuntimeVisibleParameterAnnotations = "RuntimeVisibleParameterAnnotations"; public const string RuntimeInvisibleParameterAnnotations = "RuntimeInvisibleParameterAnnotations"; + public const string RuntimeVisibleTypeAnnotations = "RuntimeVisibleTypeAnnotations"; + public const string RuntimeInvisibleTypeAnnotations = "RuntimeInvisibleTypeAnnotations"; ushort nameIndex; @@ -85,6 +88,10 @@ public string Name { { typeof (ModulePackagesAttribute), ModulePackages }, { typeof (RuntimeVisibleAnnotationsAttribute), RuntimeVisibleAnnotations }, { typeof (RuntimeInvisibleAnnotationsAttribute), RuntimeInvisibleAnnotations }, + { typeof (RuntimeVisibleParameterAnnotationsAttribute), RuntimeVisibleParameterAnnotations }, + { typeof (RuntimeInvisibleParameterAnnotationsAttribute), RuntimeInvisibleParameterAnnotations }, + { typeof (RuntimeVisibleTypeAnnotationsAttribute), RuntimeVisibleTypeAnnotations }, + { typeof (RuntimeInvisibleTypeAnnotationsAttribute), RuntimeInvisibleTypeAnnotations }, { typeof (SignatureAttribute), Signature }, { typeof (SourceFileAttribute), SourceFile }, { typeof (StackMapTableAttribute), StackMapTable }, @@ -123,7 +130,10 @@ static AttributeInfo CreateAttribute (string name, ConstantPool constantPool, us case ModulePackages: return new ModulePackagesAttribute (constantPool, nameIndex, stream); case RuntimeVisibleAnnotations: return new RuntimeVisibleAnnotationsAttribute (constantPool, nameIndex, stream); case RuntimeInvisibleAnnotations: return new RuntimeInvisibleAnnotationsAttribute (constantPool, nameIndex, stream); + case RuntimeVisibleParameterAnnotations: return new RuntimeVisibleParameterAnnotationsAttribute (constantPool, nameIndex, stream); case RuntimeInvisibleParameterAnnotations: return new RuntimeInvisibleParameterAnnotationsAttribute (constantPool, nameIndex, stream); + case RuntimeVisibleTypeAnnotations: return new RuntimeVisibleTypeAnnotationsAttribute (constantPool, nameIndex, stream); + case RuntimeInvisibleTypeAnnotations: return new RuntimeInvisibleTypeAnnotationsAttribute (constantPool, nameIndex, stream); case Signature: return new SignatureAttribute (constantPool, nameIndex, stream); case SourceFile: return new SourceFileAttribute (constantPool, nameIndex, stream); case StackMapTable: return new StackMapTableAttribute (constantPool, nameIndex, stream); @@ -671,6 +681,31 @@ public override string ToString () } + // https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.18 + public sealed class RuntimeVisibleParameterAnnotationsAttribute : AttributeInfo + { + public IList Annotations { get; } = new List (); + + public RuntimeVisibleParameterAnnotationsAttribute (ConstantPool constantPool, ushort nameIndex, Stream stream) + : base (constantPool, nameIndex, stream) + { + var length = stream.ReadNetworkUInt32 (); + var param_count = stream.ReadNetworkByte (); + + for (var i = 0; i < param_count; ++i) { + var a = new ParameterAnnotation (constantPool, stream, i); + Annotations.Add (a); + } + } + + public override string ToString () + { + var annotations = string.Join (", ", Annotations.Select (v => v.ToString ())); + return $"RuntimeVisibleParameterAnnotationsAttribute({annotations})"; + } + } + + // https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7.19 public sealed class RuntimeInvisibleParameterAnnotationsAttribute : AttributeInfo { @@ -695,6 +730,52 @@ public override string ToString () } } + // https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.20 + public sealed class RuntimeVisibleTypeAnnotationsAttribute : AttributeInfo + { + public IList Annotations { get; } = new List (); + + public RuntimeVisibleTypeAnnotationsAttribute (ConstantPool constantPool, ushort nameIndex, Stream stream) + : base (constantPool, nameIndex, stream) + { + var length = stream.ReadNetworkUInt32 (); + var count = stream.ReadNetworkUInt16 (); + + for (int i = 0; i < count; ++i) { + Annotations.Add (new TypeAnnotation (constantPool, stream)); + } + } + + public override string ToString () + { + var annotations = string.Join (", ", Annotations.Select (v => v.ToString ())); + return $"RuntimeVisibleTypeAnnotations({annotations})"; + } + } + + // https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.21 + public sealed class RuntimeInvisibleTypeAnnotationsAttribute : AttributeInfo + { + public IList Annotations { get; } = new List (); + + public RuntimeInvisibleTypeAnnotationsAttribute (ConstantPool constantPool, ushort nameIndex, Stream stream) + : base (constantPool, nameIndex, stream) + { + var length = stream.ReadNetworkUInt32 (); + var count = stream.ReadNetworkUInt16 (); + + for (int i = 0; i < count; ++i) { + Annotations.Add (new TypeAnnotation (constantPool, stream)); + } + } + + public override string ToString () + { + var annotations = string.Join (", ", Annotations.Select (v => v.ToString ())); + return $"RuntimeInvisibleTypeAnnotations({annotations})"; + } + } + // http://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7.9 public sealed class SignatureAttribute : AttributeInfo { diff --git a/src/Xamarin.Android.Tools.Bytecode/ClassFile.cs b/src/Xamarin.Android.Tools.Bytecode/ClassFile.cs index c63f7e9c0..28e3bdfb1 100644 --- a/src/Xamarin.Android.Tools.Bytecode/ClassFile.cs +++ b/src/Xamarin.Android.Tools.Bytecode/ClassFile.cs @@ -114,6 +114,18 @@ public string PackageName { public string FullJniName => "L" + ThisClass.Name.Value + ";"; + // `package-info.class` is a synthetic class that carries + // package-level annotations (e.g. JSpecify's `@NullMarked`). + // Its simple name is `package-info`. + public bool IsPackageInfo { + get { + var name = ThisClass.Name.Value; + var slash = name.LastIndexOf ('/'); + var simple = slash < 0 ? name : name.Substring (slash + 1); + return simple == "package-info"; + } + } + public string? SourceFileName { get { var sourceFile = Attributes.Get (); diff --git a/src/Xamarin.Android.Tools.Bytecode/ClassPath.cs b/src/Xamarin.Android.Tools.Bytecode/ClassPath.cs index 03f011c3d..5cb7ce872 100644 --- a/src/Xamarin.Android.Tools.Bytecode/ClassPath.cs +++ b/src/Xamarin.Android.Tools.Bytecode/ClassPath.cs @@ -125,6 +125,18 @@ public ReadOnlyDictionary> GetPackages () x => classFiles.Where (p => p.PackageName == x).ToList ())); } + // Returns the `package-info.class` for the given package, if one + // is present on this ClassPath. Used by `XmlClassDeclarationBuilder` + // to honor package-level JSpecify `@NullMarked` annotations. + public ClassFile? GetPackageInfo (string packageName) + { + foreach (var c in classFiles) { + if (c.IsPackageInfo && c.PackageName == packageName) + return c; + } + return null; + } + public static bool IsJarFile (string jarFile) { if (jarFile == null) @@ -375,8 +387,9 @@ public XElement ToXElement () .Select (p => new XElement ("package", new XAttribute ("name", p), new XAttribute ("jni-name", p.Replace ('.', '/')), - packagesDictionary [p].OrderBy (c => c.ThisClass.Name.Value, StringComparer.OrdinalIgnoreCase) - .Select (c => new XmlClassDeclarationBuilder (c).ToXElement ())))); + packagesDictionary [p].Where (c => !c.IsPackageInfo) + .OrderBy (c => c.ThisClass.Name.Value, StringComparer.OrdinalIgnoreCase) + .Select (c => new XmlClassDeclarationBuilder (c, GetPackageInfo (p)).ToXElement ())))); FixupParametersFromDocs (api); return api; } diff --git a/src/Xamarin.Android.Tools.Bytecode/TypeAnnotation.cs b/src/Xamarin.Android.Tools.Bytecode/TypeAnnotation.cs new file mode 100644 index 000000000..6053b8960 --- /dev/null +++ b/src/Xamarin.Android.Tools.Bytecode/TypeAnnotation.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; + +namespace Xamarin.Android.Tools.Bytecode +{ + // https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.20 + public enum TypeAnnotationTargetType : byte + { + ClassTypeParameter = 0x00, + MethodTypeParameter = 0x01, + ClassExtends = 0x10, + ClassTypeParameterBound = 0x11, + MethodTypeParameterBound = 0x12, + Field = 0x13, + MethodReturn = 0x14, + MethodReceiver = 0x15, + MethodFormalParameter = 0x16, + Throws = 0x17, + LocalVariable = 0x40, + ResourceVariable = 0x41, + ExceptionParameter = 0x42, + Instanceof = 0x43, + New = 0x44, + ConstructorReference = 0x45, + MethodReference = 0x46, + Cast = 0x47, + ConstructorInvocationTypeArgument = 0x48, + MethodInvocationTypeArgument = 0x49, + ConstructorReferenceTypeArgument = 0x4A, + MethodReferenceTypeArgument = 0x4B, + } + + // https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.20 + public sealed class TypeAnnotation + { + public TypeAnnotationTargetType TargetType { get; } + + // `formal_parameter_index` for MethodFormalParameter; otherwise 0. + public int FormalParameterIndex { get; } + + // `path_length` from `type_path`; we don't retain the individual + // path entries — see AppliesToTopLevelType. + public int TypePathLength { get; } + + public Annotation Annotation { get; } + + // JSpecify-style nullness annotations only apply to the top-level + // type when there is no `type_path`. Annotations with a non-empty + // path describe inner types (e.g. `Map<@Nullable K, V>`) which the + // API XML schema cannot currently represent. + public bool AppliesToTopLevelType => TypePathLength == 0; + + public TypeAnnotation (ConstantPool constantPool, Stream stream) + { + TargetType = (TypeAnnotationTargetType) stream.ReadNetworkByte (); + + // target_info — size depends on target_type. + switch (TargetType) { + case TypeAnnotationTargetType.ClassTypeParameter: + case TypeAnnotationTargetType.MethodTypeParameter: + stream.ReadNetworkByte (); // type_parameter_index + break; + case TypeAnnotationTargetType.ClassExtends: + stream.ReadNetworkUInt16 (); // supertype_index + break; + case TypeAnnotationTargetType.ClassTypeParameterBound: + case TypeAnnotationTargetType.MethodTypeParameterBound: + stream.ReadNetworkByte (); // type_parameter_index + stream.ReadNetworkByte (); // bound_index + break; + case TypeAnnotationTargetType.Field: + case TypeAnnotationTargetType.MethodReturn: + case TypeAnnotationTargetType.MethodReceiver: + // empty_target — no bytes + break; + case TypeAnnotationTargetType.MethodFormalParameter: + FormalParameterIndex = stream.ReadNetworkByte (); + break; + case TypeAnnotationTargetType.Throws: + stream.ReadNetworkUInt16 (); // throws_type_index + break; + case TypeAnnotationTargetType.LocalVariable: + case TypeAnnotationTargetType.ResourceVariable: { + var table_length = stream.ReadNetworkUInt16 (); + for (int i = 0; i < table_length; ++i) { + stream.ReadNetworkUInt16 (); // start_pc + stream.ReadNetworkUInt16 (); // length + stream.ReadNetworkUInt16 (); // index + } + break; + } + case TypeAnnotationTargetType.ExceptionParameter: + stream.ReadNetworkUInt16 (); // exception_table_index + break; + case TypeAnnotationTargetType.Instanceof: + case TypeAnnotationTargetType.New: + case TypeAnnotationTargetType.ConstructorReference: + case TypeAnnotationTargetType.MethodReference: + stream.ReadNetworkUInt16 (); // offset + break; + case TypeAnnotationTargetType.Cast: + case TypeAnnotationTargetType.ConstructorInvocationTypeArgument: + case TypeAnnotationTargetType.MethodInvocationTypeArgument: + case TypeAnnotationTargetType.ConstructorReferenceTypeArgument: + case TypeAnnotationTargetType.MethodReferenceTypeArgument: + stream.ReadNetworkUInt16 (); // offset + stream.ReadNetworkByte (); // type_argument_index + break; + default: + throw new NotSupportedException ($"Unknown type_annotation target_type: 0x{(byte)TargetType:X2}"); + } + + // type_path: u1 path_length, then path_length * { u1 type_path_kind; u1 type_argument_index; } + TypePathLength = stream.ReadNetworkByte (); + for (int i = 0; i < TypePathLength; ++i) { + stream.ReadNetworkByte (); // type_path_kind + stream.ReadNetworkByte (); // type_argument_index + } + + // The remaining bytes match the regular `annotation` structure. + Annotation = new Annotation (constantPool, stream); + } + + public override string ToString () + { + return $"TypeAnnotation({TargetType}, paramIndex={FormalParameterIndex}, pathLength={TypePathLength}, {Annotation})"; + } + } +} diff --git a/src/Xamarin.Android.Tools.Bytecode/XmlClassDeclarationBuilder.cs b/src/Xamarin.Android.Tools.Bytecode/XmlClassDeclarationBuilder.cs index 6ec53e39b..d595a10ed 100644 --- a/src/Xamarin.Android.Tools.Bytecode/XmlClassDeclarationBuilder.cs +++ b/src/Xamarin.Android.Tools.Bytecode/XmlClassDeclarationBuilder.cs @@ -11,19 +11,69 @@ namespace Xamarin.Android.Tools.Bytecode { public class XmlClassDeclarationBuilder { ClassFile classFile; + ClassFile? packageInfo; ClassSignature? signature; + bool isNullMarked; bool IsInterface { get {return (classFile.AccessFlags & ClassAccessFlags.Interface) != 0;} } public XmlClassDeclarationBuilder (ClassFile classFile) + : this (classFile, packageInfo: null) + { + } + + public XmlClassDeclarationBuilder (ClassFile classFile, ClassFile? packageInfo) { if (classFile == null) throw new ArgumentNullException ("classFile"); - this.classFile = classFile; - signature = classFile.GetSignature (); + this.classFile = classFile; + this.packageInfo = packageInfo; + signature = classFile.GetSignature (); + isNullMarked = ComputeIsNullMarked (); + } + + // Module-level and inner-class scope inheritance are not yet implemented. + bool ComputeIsNullMarked () + { + var classScope = GetJSpecifyScope (classFile.Attributes); + if (classScope.HasValue) + return classScope.Value; + + if (packageInfo != null) { + var pkgScope = GetJSpecifyScope (packageInfo.Attributes); + if (pkgScope.HasValue) + return pkgScope.Value; + } + + return false; + } + + // `@NullMarked` / `@NullUnmarked` are `@Retention(RUNTIME)`, so + // check both Visible and Invisible annotation tables. + static bool? GetJSpecifyScope (AttributeCollection? attributes) + { + if (attributes == null) + return null; + foreach (var a in EnumerateDeclarationAnnotations (attributes)) { + if (a.Type == "Lorg/jspecify/annotations/NullUnmarked;") + return false; + if (a.Type == "Lorg/jspecify/annotations/NullMarked;") + return true; + } + return null; + } + + static IEnumerable EnumerateDeclarationAnnotations (AttributeCollection attributes) + { + foreach (var v in attributes.OfType ()) + foreach (var a in v.Annotations) + yield return a; + foreach (var i in attributes.OfType ()) + foreach (var a in i.Annotations) + yield return a; } public XElement ToXElement () @@ -425,7 +475,16 @@ static string GetVisibility (MethodAccessFlags accessFlags) IEnumerable GetMethodParameters (MethodInfo method) { - var annotations = method.Attributes?.OfType ().FirstOrDefault ()?.Annotations; + var invisible = method.Attributes?.OfType ().FirstOrDefault ()?.Annotations; + var visible = method.Attributes?.OfType ().FirstOrDefault ()?.Annotations; + IList? annotations = invisible; + if (annotations == null) { + annotations = visible; + } else if (visible != null) { + var merged = new List (annotations); + merged.AddRange (visible); + annotations = merged; + } var varargs = (method.AccessFlags & MethodAccessFlags.Varargs) != 0; var parameters = method.GetParameters (); for (int i = 0; i < parameters.Length; ++i) { @@ -453,7 +512,7 @@ IEnumerable GetMethodParameters (MethodInfo method) new XAttribute ("type", genericType), new XAttribute ("jni-type", p.Type.TypeSignature ?? p.Type.BinaryName), GetKotlinInlineClassJniType (p), - GetNotNull (annotations, i)); + GetNotNull (method, annotations, i)); } } @@ -513,34 +572,155 @@ IEnumerable GetExceptions (MethodInfo method) return null; } - static XAttribute? GetNotNull (MethodInfo method) + XAttribute? GetNotNull (MethodInfo method) { - var annotations = method.Attributes?.OfType ().FirstOrDefault ()?.Annotations; - - if (annotations?.Any (a => IsNotNullAnnotation (a)) == true) + var nullness = GetMethodReturnNullness (method); + if (nullness == true) return new XAttribute ("return-not-null", "true"); + return null; + } + XAttribute? GetNotNull (MethodInfo method, IList? annotations, int parameterIndex) + { + var nullness = GetParameterNullness (method, annotations, parameterIndex); + if (nullness == true) + return new XAttribute ("not-null", "true"); return null; } - static XAttribute? GetNotNull (IList? annotations, int parameterIndex) + XAttribute? GetNotNull (FieldInfo field) { - var ann = annotations?.FirstOrDefault (a => a.ParameterIndex == parameterIndex)?.Annotations; + var nullness = GetFieldNullness (field); + if (nullness == true) + return new XAttribute ("not-null", "true"); + return null; + } + // Returns true when the slot is known non-null, false when known + // nullable, null when no information. JSpecify TYPE_USE `@Nullable` + // (top-of-type only) overrides the scope default; an explicit + // declaration-level `@NonNull`-style annotation always wins. + bool? GetMethodReturnNullness (MethodInfo method) + { + if (HasDeclarationNotNullAnnotation (method.Attributes)) + return true; + var typeNullness = GetTypeUseNullness (method.Attributes, + ta => ta.TargetType == TypeAnnotationTargetType.MethodReturn); + if (typeNullness.HasValue) + return typeNullness; + if (isNullMarked && IsReferenceTypeDescriptor (GetReturnDescriptor (method.Descriptor))) + return true; + return null; + } + + bool? GetParameterNullness (MethodInfo method, IList? annotations, int parameterIndex) + { + var ann = annotations?.FirstOrDefault (a => a.ParameterIndex == parameterIndex)?.Annotations; if (ann?.Any (a => IsNotNullAnnotation (a)) == true) - return new XAttribute ("not-null", "true"); + return true; + + var typeNullness = GetTypeUseNullness (method.Attributes, + ta => ta.TargetType == TypeAnnotationTargetType.MethodFormalParameter + && ta.FormalParameterIndex == parameterIndex); + if (typeNullness.HasValue) + return typeNullness; + + if (isNullMarked) { + var parameters = method.GetParameters (); + if (parameterIndex >= 0 && parameterIndex < parameters.Length + && IsReferenceTypeDescriptor (parameters [parameterIndex].Type.BinaryName)) + return true; + } + return null; + } + bool? GetFieldNullness (FieldInfo field) + { + if (HasDeclarationNotNullAnnotation (field.Attributes)) + return true; + var typeNullness = GetTypeUseNullness (field.Attributes, + ta => ta.TargetType == TypeAnnotationTargetType.Field); + if (typeNullness.HasValue) + return typeNullness; + if (isNullMarked && IsReferenceTypeDescriptor (field.Descriptor)) + return true; return null; } - static XAttribute? GetNotNull (FieldInfo field) + static bool HasDeclarationNotNullAnnotation (AttributeCollection? attributes) { - var annotations = field.Attributes?.OfType ().FirstOrDefault ()?.Annotations; + if (attributes == null) + return false; + foreach (var a in EnumerateDeclarationAnnotations (attributes)) { + if (IsNotNullAnnotation (a)) + return true; + } + return false; + } - if (annotations?.Any (a => IsNotNullAnnotation (a)) == true) - return new XAttribute ("not-null", "true"); + // Look in `RuntimeInvisibleTypeAnnotations` and `RuntimeVisibleTypeAnnotations` + // for entries matching `predicate` (e.g. METHOD_RETURN, FIELD, or + // a specific METHOD_FORMAL_PARAMETER index) at the top of the type + // (no `type_path`). Returns true for `@NonNull`, false for + // `@Nullable`, null for no match. + static bool? GetTypeUseNullness (AttributeCollection? attributes, Func predicate) + { + if (attributes == null) + return null; + bool? result = null; + foreach (var ta in EnumerateTypeAnnotations (attributes)) { + if (!ta.AppliesToTopLevelType) + continue; + if (!predicate (ta)) + continue; + if (IsNotNullAnnotation (ta.Annotation)) + return true; + if (IsNullableAnnotation (ta.Annotation)) + result = false; + } + return result; + } - return null; + static IEnumerable EnumerateTypeAnnotations (AttributeCollection attributes) + { + foreach (var v in attributes.OfType ()) + foreach (var a in v.Annotations) + yield return a; + foreach (var i in attributes.OfType ()) + foreach (var a in i.Annotations) + yield return a; + } + + static bool IsNullableAnnotation (Annotation annotation) + { + switch (annotation.Type) { + case "Lorg/jspecify/annotations/Nullable;": + case "Landroidx/annotation/Nullable;": + case "Landroid/annotation/Nullable;": + case "Landroid/support/annotation/Nullable;": + return true; + } + return false; + } + + // Returns the return-type portion of a method descriptor, e.g. + // "(ILjava/lang/String;)Ljava/lang/Object;" -> "Ljava/lang/Object;". + static string GetReturnDescriptor (string descriptor) + { + var i = descriptor.LastIndexOf (')'); + return i < 0 ? descriptor : descriptor.Substring (i + 1); + } + + // A descriptor refers to a reference type (object or array) iff + // it starts with 'L' (object), 'T' (type variable — appears in + // `Signature`, not `Descriptor`, but harmless to include), or '[' + // (array of anything). Primitives and `V` (void) return false. + static bool IsReferenceTypeDescriptor (string descriptor) + { + if (string.IsNullOrEmpty (descriptor)) + return false; + var c = descriptor [0]; + return c == 'L' || c == '[' || c == 'T'; } static bool IsNotNullAnnotation (Annotation annotation) diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/JSpecifyAnnotationTests.cs b/tests/Xamarin.Android.Tools.Bytecode-Tests/JSpecifyAnnotationTests.cs new file mode 100644 index 000000000..5fb655568 --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/JSpecifyAnnotationTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +#nullable enable + +using Xamarin.Android.Tools.Bytecode; + +using NUnit.Framework; + +namespace Xamarin.Android.Tools.BytecodeTests +{ + [TestFixture] + public class JSpecifyAnnotationTests : ClassFileFixture + { + static XElement BuildXml (string classResource, string? packageInfoResource = null) + { + var classPath = new ClassPath (); + if (packageInfoResource != null) + classPath.Add (LoadClassFile (packageInfoResource)); + var c = LoadClassFile (classResource); + classPath.Add (c); + + var packageInfo = packageInfoResource != null + ? classPath.GetPackageInfo (c.PackageName) + : null; + return new XmlClassDeclarationBuilder (c, packageInfo).ToXElement (); + } + + static XElement Method (XElement classXml, string name) + => classXml.Elements ().First (e => (e.Name.LocalName == "method" || e.Name.LocalName == "constructor") + && e.Attribute ("name")?.Value == name); + + static XElement Field (XElement classXml, string name) + => classXml.Elements ("field").First (f => f.Attribute ("name")?.Value == name); + + static string? Attr (XElement e, string name) => e.Attribute (name)?.Value; + + [Test] + public void PackageMarked_DefaultReferenceMembers_AreNotNull () + { + var xml = BuildXml ("JSpecifyPackageMarked.class", "package-info.class"); + + var m = Method (xml, "defaultReturn"); + Assert.AreEqual ("true", Attr (m, "return-not-null"), + "unannotated reference return in @NullMarked package must be not-null"); + + var p = m.Element ("parameter"); + Assert.AreEqual ("true", Attr (p!, "not-null"), + "unannotated reference parameter in @NullMarked package must be not-null"); + + var f = Field (xml, "defaultField"); + Assert.AreEqual ("true", Attr (f, "not-null"), + "unannotated reference field in @NullMarked package must be not-null"); + } + + [Test] + public void PackageMarked_PrimitiveMembers_HaveNoNotNullAttribute () + { + var xml = BuildXml ("JSpecifyPackageMarked.class", "package-info.class"); + + var m = Method (xml, "primitiveReturn"); + Assert.IsNull (Attr (m, "return-not-null"), + "primitive return must not get a not-null attribute"); + + var f = Field (xml, "primitiveField"); + Assert.IsNull (Attr (f, "not-null"), + "primitive field must not get a not-null attribute"); + } + + [Test] + public void PackageMarked_NullableAnnotationOverridesScope () + { + var xml = BuildXml ("JSpecifyPackageMarked.class", "package-info.class"); + + var m = Method (xml, "nullableReturn"); + Assert.IsNull (Attr (m, "return-not-null"), + "@Nullable return must override the @NullMarked default"); + + var p = m.Element ("parameter"); + Assert.IsNull (Attr (p!, "not-null"), + "@Nullable parameter must override the @NullMarked default"); + + var f = Field (xml, "nullableField"); + Assert.IsNull (Attr (f, "not-null"), + "@Nullable field must override the @NullMarked default"); + } + + [Test] + public void PackageMarked_NullUnmarkedMethod_RevertsToUnknown () + { + var xml = BuildXml ("JSpecifyPackageMarked.class", "package-info.class"); + + var m = Method (xml, "unmarkedReturn"); + Assert.AreEqual ("true", Attr (m, "return-not-null"), + "method-level @NullUnmarked is not yet honored (documented limitation)"); + } + + [Test] + public void ClassMarked_DefaultReferenceMembers_AreNotNull () + { + var xml = BuildXml ("JSpecifyClassMarked.class"); + + var m = Method (xml, "defaultReturn"); + Assert.AreEqual ("true", Attr (m, "return-not-null"), + "unannotated reference return in @NullMarked class must be not-null"); + + var p = m.Element ("parameter"); + Assert.AreEqual ("true", Attr (p!, "not-null"), + "unannotated reference parameter in @NullMarked class must be not-null"); + + var f = Field (xml, "defaultField"); + Assert.AreEqual ("true", Attr (f, "not-null"), + "unannotated reference field in @NullMarked class must be not-null"); + } + + [Test] + public void ClassMarked_NullableOverrides () + { + var xml = BuildXml ("JSpecifyClassMarked.class"); + + var m = Method (xml, "nullableReturn"); + Assert.IsNull (Attr (m, "return-not-null"), + "@Nullable return must override the class-level @NullMarked default"); + + var p = m.Element ("parameter"); + Assert.IsNull (Attr (p!, "not-null"), + "@Nullable parameter must override the class-level @NullMarked default"); + + var f = Field (xml, "nullableField"); + Assert.IsNull (Attr (f, "not-null"), + "@Nullable field must override the class-level @NullMarked default"); + } + + [Test] + public void Unmarked_NoScope_NoDefaultNotNull () + { + var xml = BuildXml ("JSpecifyUnmarked.class"); + + var m = Method (xml, "defaultReturn"); + Assert.IsNull (Attr (m, "return-not-null"), + "outside a @NullMarked scope, unannotated reference returns must not gain not-null"); + + var p = m.Element ("parameter"); + Assert.IsNull (Attr (p!, "not-null"), + "outside a @NullMarked scope, unannotated reference parameters must not gain not-null"); + + var f = Field (xml, "defaultField"); + Assert.IsNull (Attr (f, "not-null"), + "outside a @NullMarked scope, unannotated reference fields must not gain not-null"); + } + + [Test] + public void TypeAnnotationsAttribute_IsParsed () + { + var c = LoadClassFile ("JSpecifyPackageMarked.class"); + var method = c.Methods.First (m => m.Name == "nullableReturn"); + var typeAnn = method.Attributes + .OfType () + .FirstOrDefault (); + Assert.NotNull (typeAnn, + "javac must emit a RuntimeInvisibleTypeAnnotations attribute for @Nullable members"); + Assert.IsTrue (typeAnn!.Annotations.Any (a => + a.Annotation.Type == "Lorg/jspecify/annotations/Nullable;" + && a.TargetType == TypeAnnotationTargetType.MethodReturn), + "@Nullable return type annotation must be parsed with MethodReturn target"); + Assert.IsTrue (typeAnn!.Annotations.Any (a => + a.Annotation.Type == "Lorg/jspecify/annotations/Nullable;" + && a.TargetType == TypeAnnotationTargetType.MethodFormalParameter + && a.FormalParameterIndex == 0), + "@Nullable parameter type annotation must be parsed with MethodFormalParameter target"); + } + + [Test] + public void PackageInfo_IsRecognized () + { + var c = LoadClassFile ("package-info.class"); + Assert.IsTrue (c.IsPackageInfo, + "package-info.class must be flagged via IsPackageInfo"); + } + } +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj b/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj index 743be6fd0..b15ca040e 100644 --- a/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj @@ -35,6 +35,10 @@ + + + + diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/JSpecifyClassMarked.java b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/JSpecifyClassMarked.java new file mode 100644 index 000000000..f2454a8dd --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/JSpecifyClassMarked.java @@ -0,0 +1,23 @@ +package com.jspecifytest; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Class-level `@NullMarked` inside an already-marked package, with a + * `@Nullable` opt-out, exercises the class-level scope code path. + */ +@NullMarked +public class JSpecifyClassMarked { + + public String defaultReturn (String value) { + return value; + } + + public @Nullable String nullableReturn (@Nullable String value) { + return value; + } + + public String defaultField; + public @Nullable String nullableField; +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/JSpecifyPackageMarked.java b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/JSpecifyPackageMarked.java new file mode 100644 index 000000000..acb125882 --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/JSpecifyPackageMarked.java @@ -0,0 +1,39 @@ +package com.jspecifytest; + +import org.jspecify.annotations.Nullable; +import org.jspecify.annotations.NullUnmarked; + +/** + * Lives inside a `@NullMarked` package. Without any annotations, + * all reference-typed return values, parameters, and fields should + * be considered non-null. + */ +public class JSpecifyPackageMarked { + + // Reference return / param / field with no annotations -> non-null. + public String defaultReturn (String value) { + return value; + } + + public String defaultField; + + // Primitive return / field — never gets a `not-null` attribute. + public int primitiveReturn () { + return 0; + } + + public int primitiveField; + + // TYPE_USE `@Nullable` overrides scope default. + public @Nullable String nullableReturn (@Nullable String value) { + return value; + } + + public @Nullable String nullableField; + + // `@NullUnmarked` at the method level reverts the scope. + @NullUnmarked + public String unmarkedReturn (String value) { + return value; + } +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/package-info.java b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/package-info.java new file mode 100644 index 000000000..6fc7ede5d --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.jspecifytest; + +import org.jspecify.annotations.NullMarked; diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifyunmarked/JSpecifyUnmarked.java b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifyunmarked/JSpecifyUnmarked.java new file mode 100644 index 000000000..2d02deeb7 --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifyunmarked/JSpecifyUnmarked.java @@ -0,0 +1,21 @@ +package com.jspecifyunmarked; + +import org.jspecify.annotations.Nullable; + +/** + * Class in a package with no `package-info.class` and no class-level + * `@NullMarked`. Only explicit annotations should produce nullness + * output; the rest should be unknown (no attribute). + */ +public class JSpecifyUnmarked { + + public String defaultReturn (String value) { + return value; + } + + public @Nullable String nullableReturn (@Nullable String value) { + return value; + } + + public String defaultField; +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NonNull.java b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NonNull.java new file mode 100644 index 000000000..41e4bc461 --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NonNull.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target(ElementType.TYPE_USE) +@Retention(RetentionPolicy.CLASS) +public @interface NonNull { +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NullMarked.java b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NullMarked.java new file mode 100644 index 000000000..a2b88c37f --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NullMarked.java @@ -0,0 +1,15 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ + ElementType.MODULE, + ElementType.PACKAGE, + ElementType.TYPE, + ElementType.METHOD, + ElementType.CONSTRUCTOR, + ElementType.FIELD, +}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullMarked { +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NullUnmarked.java b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NullUnmarked.java new file mode 100644 index 000000000..d3f5ea8c4 --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NullUnmarked.java @@ -0,0 +1,15 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ + ElementType.MODULE, + ElementType.PACKAGE, + ElementType.TYPE, + ElementType.METHOD, + ElementType.CONSTRUCTOR, + ElementType.FIELD, +}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullUnmarked { +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/Nullable.java b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/Nullable.java new file mode 100644 index 000000000..8dfa3e997 --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/Nullable.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target(ElementType.TYPE_USE) +@Retention(RetentionPolicy.CLASS) +public @interface Nullable { +}