diff --git a/compiler/src/dotty/tools/dotc/core/Definitions.scala b/compiler/src/dotty/tools/dotc/core/Definitions.scala index 97f7d944bcf5..c47caa380589 100644 --- a/compiler/src/dotty/tools/dotc/core/Definitions.scala +++ b/compiler/src/dotty/tools/dotc/core/Definitions.scala @@ -1225,6 +1225,24 @@ class Definitions { "io.reactivex.rxjava3.annotations.Nullable" :: "org.jspecify.annotations.Nullable" :: Nil) + // Scope-level markers (JSpecify) that flip the implicit-nulls default within their scope: + // inside a `@NullMarked` module/package/class/method, unannotated reference types are non-null, + // and only an explicit `@Nullable` reintroduces nullability. `@NullUnmarked` re-enables the + // implicit-nulls default within an enclosing `@NullMarked` scope. As with the other null + // annotations, we don't require these to be on the class path. + @tu lazy val NullMarkedAnnots: List[ClassSymbol] = getClassesIfDefined( + "org.jspecify.annotations.NullMarked" :: Nil) + + @tu lazy val NullUnmarkedAnnots: List[ClassSymbol] = getClassesIfDefined( + "org.jspecify.annotations.NullUnmarked" :: Nil) + + /** The name of the synthetic member that carries a package's annotations, loaded from + * `package-info.java` / `package-info.class` (`package-info` is not a legal Java identifier, + * so it never collides with a real member). Used by the explicit-nulls interop to read a + * package-level `@NullMarked` / `@NullUnmarked`. + */ + val PackageInfoName: TypeName = typeName("package-info") + // convenient one-parameter method types def methOfAny(tp: Type): MethodType = MethodType(List(AnyType), tp) def methOfAnyVal(tp: Type): MethodType = MethodType(List(AnyValType), tp) diff --git a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala index 7350af348ea0..2f9d928e7142 100644 --- a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala +++ b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala @@ -81,11 +81,16 @@ object ImplicitNullInterop: || sym.is(Flags.ModuleVal) then return tp + // In a JSpecify `@NullMarked` scope, unannotated reference types are non-null by default, + // so the ambient mode becomes `Skip` (nothing is nullified unless an explicit annotation + // says otherwise). Otherwise the ambient mode is the usual `Default`. + val ambient = if isNullMarked(sym) then NullMode.Skip else NullMode.Default + val currentTypeMode = // Don't nullify Given/implicit parameters if sym.isOneOf(GivenOrImplicitVal) || hasNotNullAnnot(sym) then NullMode.Skip else if hasNullableAnnot(sym) then NullMode.Explicit - else NullMode.Default + else ambient val resultTypeMode = // Don't nullify result type of constructors @@ -94,9 +99,43 @@ object ImplicitNullInterop: ImplicitNullMap( javaDefined = sym.is(JavaDefined), + ambient = ambient, state = NullMapState(resultTypeMode, currentTypeMode) )(tp) + /** Is `sym` in a JSpecify `@NullMarked` scope? We walk the owner chain (starting at `sym` + * itself) and let the nearest scope marking win: `@NullMarked` enables the non-null default, + * `@NullUnmarked` re-enables the implicit-nulls default. + */ + private def isNullMarked(sym: Symbol)(using Context): Boolean = + sym.ownersIterator.map(ownerNullMarking).collectFirst { case Some(marked) => marked }.getOrElse(false) + + /** The scope marking declared directly on `owner`, if any: `Some(true)` for `@NullMarked`, + * `Some(false)` for `@NullUnmarked`, `None` if `owner` declares neither. Per JSpecify, a + * declaration carrying *both* markers behaves as if it carried neither, so we return `None` and + * let an enclosing scope decide. + * + * For packages the marker is placed on `package-info` (from `package-info.java` / + * `package-info.class`), which is loaded as a synthetic `package-info` member of the package. + * We force that member to read its annotations (safe: it only depends on the annotation + * classes). For all other owners we use `unforcedAnnotation` to avoid forcing symbols that may + * still be under construction during classfile loading / unpickling. + */ + private def ownerNullMarking(owner: Symbol)(using Context): Option[Boolean] = + val (carrier, forced) = + // For packages the marker lives on the synthetic `package-info` member, which we force. + if owner.is(Package) then (owner.info.decl(defn.PackageInfoName).symbol, true) + else (owner, false) + if !carrier.exists then None + else + def has(annots: List[ClassSymbol]): Boolean = + if forced then annots.exists(carrier.hasAnnotation(_)) + else annots.exists(carrier.unforcedAnnotation(_).isDefined) + val marked = has(defn.NullMarkedAnnots) + val unmarked = has(defn.NullUnmarkedAnnots) + if marked == unmarked then None // both or neither present: behave as if neither + else Some(marked) + private def hasNotNullAnnot(sym: Symbol)(using Context): Boolean = defn.NotNullAnnots.exists(sym.unforcedAnnotation(_).isDefined) @@ -115,10 +154,14 @@ object ImplicitNullInterop: ) object NullMapState: - def skipCurrentIf(cond: Boolean)(using Context): NullMapState = + /** Reset to a nested position: the result type mode becomes the ambient default, and the + * current-level mode is `Skip` when `cond` holds, otherwise the ambient default. The ambient + * default is `Default` normally, or `Skip` inside a `@NullMarked` scope. + */ + def skipCurrentIf(cond: Boolean, ambient: NullMode): NullMapState = NullMapState( - resultTypeMode = NullMode.Default, - currentTypeMode = if cond then NullMode.Skip else NullMode.Default + resultTypeMode = ambient, + currentTypeMode = if cond then NullMode.Skip else ambient ) /** A type map that implements the nullification function on types. Given a Java-sourced type or a type @@ -126,11 +169,14 @@ object ImplicitNullInterop: * right places to make nullability explicit in a conservative way (without forcing incomplete symbols). * * @param javaDefined whether the type is from Java source; we always nullify type param refs from Java + * @param ambient the default mode for unannotated positions: `Default` normally, or `Skip` inside + * a JSpecify `@NullMarked` scope (where unannotated reference types are non-null). * @param state mutable nullification state tracking the current mode for the result type * (`resultTypeMode`) and the current nesting level (`currentTypeMode`). */ private class ImplicitNullMap( val javaDefined: Boolean, + val ambient: NullMode, var state: NullMapState )(using Context) extends TypeMap: @@ -201,7 +247,7 @@ object ImplicitNullInterop: case appTp @ AppliedType(tycon, targs) => val savedState = state // If Java-defined tycon, don't nullify outer level of type args (Java classes are fully nullified) - state = NullMapState.skipCurrentIf(tp.classSymbol.is(JavaDefined)) + state = NullMapState.skipCurrentIf(tp.classSymbol.is(JavaDefined), ambient) val targs2 = targs.map(this) state = savedState @@ -213,11 +259,11 @@ object ImplicitNullInterop: val savedState = state // Don't nullify param types for implicit/using sections - state = NullMapState.skipCurrentIf(mtp.isImplicitMethod) + state = NullMapState.skipCurrentIf(mtp.isImplicitMethod, ambient) val paramInfos2 = mtp.paramInfos.map(this) state = NullMapState( - resultTypeMode = NullMode.Default, + resultTypeMode = ambient, currentTypeMode = savedState.resultTypeMode ) val resType2 = this(mtp.resType) diff --git a/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala b/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala index 67b9f25de98a..2ba1ffe8cab3 100644 --- a/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala @@ -1110,10 +1110,13 @@ object JavaParsers { val buf = ListBuffer.empty[Tree] var start = in.offset val leadingAnnots = if (in.token == AT) annotations() else Nil + // Annotations placed before a `package` declaration (only legal in `package-info.java`). + var packageAnnots: List[Tree] = Nil val pkg: RefTree = if in.token == PACKAGE then if leadingAnnots.nonEmpty then start = in.offset + packageAnnots = leadingAnnots accept(PACKAGE) val pkg = qualId() accept(SEMI) @@ -1136,6 +1139,17 @@ object JavaParsers { if buf.isEmpty then while (in.token == IMPORT) buf ++= importDecl() + // Retain package-level annotations (e.g. JSpecify `@NullMarked`) by attaching them to a + // synthetic `package-info` class, mirroring how `package-info.class` represents them. This + // lets downstream logic (nullification) read them via the package's `package-info` member. + // It is added after imports so the annotation names resolve against them. + if packageAnnots.nonEmpty then + buf += atSpan(start) { + TypeDef( + defn.PackageInfoName, + makeTemplate(List(ObjectTpt()), Nil, Nil, needsDummyConstr = true) + ).withMods(Modifiers(Flags.JavaDefined).withAnnotations(packageAnnots)) + } while (in.token != EOF && in.token != RBRACE) { while (in.token == SEMI) in.nextToken() if (in.token != EOF) { diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/J.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/J.java new file mode 100644 index 000000000000..1fe11746d864 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/J.java @@ -0,0 +1,26 @@ +package nmpkg; + +import org.jspecify.annotations.*; + +// No class-level annotation: nullability is decided by the package's `@NullMarked` (package-info). +public class J { + + // Unannotated -> non-null String (package is null-marked). + public String get() { + return ""; + } + + // Type-use `@Nullable` still reintroduces nullability -> String | Null. + public @Nullable String getNullable() { + return null; + } + + // Unannotated parameter -> non-null String. + public void set(String s) { + } + + // `@NullUnmarked` overrides the package marking for this method. + @NullUnmarked + public void unmarkedSet(String s) { + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullMarked.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullMarked.java new file mode 100644 index 000000000000..94c512851f43 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullMarked.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullMarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullUnmarked.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullUnmarked.java new file mode 100644 index 000000000000..d792e8c8c1f3 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullUnmarked.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullUnmarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/Nullable.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/Nullable.java new file mode 100644 index 000000000000..b5624d982f0d --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/Nullable.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Nullable { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala new file mode 100644 index 000000000000..b5921d953ba4 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala @@ -0,0 +1,32 @@ +// Test that a package-level `@NullMarked` (from `package-info.class`) is read from class files. +// The Java sources above are compiled first (round 0); this file is compiled separately and reads +// `nmpkg.J` through ClassfileParser. +//> using options -Yno-flexible-types + +import nmpkg.J +import nmpkg.U + +// `get` result is non-null because the package is `@NullMarked`. +def a(j: J): String = j.get() + +// Type-use `@Nullable` still makes the result `String | Null`. +def b(j: J): String = j.getNullable() // error + +// `set` parameter is non-null, so `null` is rejected. +def c(j: J): Unit = j.set(null) // error + +// `@NullUnmarked` on the method overrides the package marking, so `null` is accepted here. +def d(j: J): Unit = j.unmarkedSet(null) + +// `@NullUnmarked` on class `U` overrides the package `@NullMarked`: `get` is `String | Null`. +def u1(u: U): String = u.get() // error + +// `@NullUnmarked` class: `set` parameter is nullable, so `null` is accepted. +def u2(u: U): Unit = u.set(null) + +// `@NullMarked` re-marks the method inside the unmarked class, so its result is non-null. +def u3(u: U): String = u.markedGet() + +// Both markers behave as if neither: `bothGet` inherits `U`'s `@NullUnmarked`, so its result is +// `String | Null` (it does not become non-null from `@NullMarked`). +def u4(u: U): String = u.bothGet() // error diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/U.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/U.java new file mode 100644 index 000000000000..ef0ca64ff427 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/U.java @@ -0,0 +1,32 @@ +package nmpkg; + +import org.jspecify.annotations.*; + +// `@NullUnmarked` on a top-level class overrides the package's `@NullMarked`, +// restoring the implicit-nulls default for its members. +@NullUnmarked +public class U { + + // Unmarked -> String | Null. + public String get() { + return ""; + } + + // Unmarked parameter -> nullable. + public void set(String s) { + } + + // `@NullMarked` re-marks this method, so its result is non-null again. + @NullMarked + public String markedGet() { + return ""; + } + + // Both markers behave as if neither is present, so this inherits `U`'s `@NullUnmarked` + // (its result is `String | Null`), rather than letting `@NullMarked` win. + @NullMarked + @NullUnmarked + public String bothGet() { + return ""; + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java new file mode 100644 index 000000000000..9408d05cee65 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java @@ -0,0 +1,6 @@ +// The whole package is `@NullMarked` via `package-info.java`, so unannotated reference types +// in this package are non-null. JavaParsers must read this package-level annotation. +@NullMarked +package nmpkg; + +import org.jspecify.annotations.*; \ No newline at end of file diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/J.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/J.java new file mode 100644 index 000000000000..1fe11746d864 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/J.java @@ -0,0 +1,26 @@ +package nmpkg; + +import org.jspecify.annotations.*; + +// No class-level annotation: nullability is decided by the package's `@NullMarked` (package-info). +public class J { + + // Unannotated -> non-null String (package is null-marked). + public String get() { + return ""; + } + + // Type-use `@Nullable` still reintroduces nullability -> String | Null. + public @Nullable String getNullable() { + return null; + } + + // Unannotated parameter -> non-null String. + public void set(String s) { + } + + // `@NullUnmarked` overrides the package marking for this method. + @NullUnmarked + public void unmarkedSet(String s) { + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/NullMarked.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/NullMarked.java new file mode 100644 index 000000000000..94c512851f43 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/NullMarked.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullMarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/NullUnmarked.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/NullUnmarked.java new file mode 100644 index 000000000000..d792e8c8c1f3 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/NullUnmarked.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullUnmarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/Nullable.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/Nullable.java new file mode 100644 index 000000000000..b5624d982f0d --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/Nullable.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Nullable { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala b/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala new file mode 100644 index 000000000000..31ad924f0bc7 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala @@ -0,0 +1,29 @@ +//> using options -Yno-flexible-types + +import nmpkg.J +import nmpkg.U + +// `get` result is non-null because the package is `@NullMarked`. +def a(j: J): String = j.get() + +// Type-use `@Nullable` still makes the result `String | Null`. +def b(j: J): String = j.getNullable() // error + +// `set` parameter is non-null, so `null` is rejected. +def c(j: J): Unit = j.set(null) // error + +// `@NullUnmarked` on the method overrides the package marking, so `null` is accepted here. +def d(j: J): Unit = j.unmarkedSet(null) + +// `@NullUnmarked` on class `U` overrides the package `@NullMarked`: `get` is `String | Null`. +def u1(u: U): String = u.get() // error + +// `@NullUnmarked` class: `set` parameter is nullable, so `null` is accepted. +def u2(u: U): Unit = u.set(null) + +// `@NullMarked` re-marks the method inside the unmarked class, so its result is non-null. +def u3(u: U): String = u.markedGet() + +// Both markers behave as if neither: `bothGet` inherits `U`'s `@NullUnmarked`, so its result is +// `String | Null` (it does not become non-null from `@NullMarked`). +def u4(u: U): String = u.bothGet() // error diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/U.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/U.java new file mode 100644 index 000000000000..ef0ca64ff427 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/U.java @@ -0,0 +1,32 @@ +package nmpkg; + +import org.jspecify.annotations.*; + +// `@NullUnmarked` on a top-level class overrides the package's `@NullMarked`, +// restoring the implicit-nulls default for its members. +@NullUnmarked +public class U { + + // Unmarked -> String | Null. + public String get() { + return ""; + } + + // Unmarked parameter -> nullable. + public void set(String s) { + } + + // `@NullMarked` re-marks this method, so its result is non-null again. + @NullMarked + public String markedGet() { + return ""; + } + + // Both markers behave as if neither is present, so this inherits `U`'s `@NullUnmarked` + // (its result is `String | Null`), rather than letting `@NullMarked` win. + @NullMarked + @NullUnmarked + public String bothGet() { + return ""; + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java new file mode 100644 index 000000000000..9408d05cee65 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java @@ -0,0 +1,6 @@ +// The whole package is `@NullMarked` via `package-info.java`, so unannotated reference types +// in this package are non-null. JavaParsers must read this package-level annotation. +@NullMarked +package nmpkg; + +import org.jspecify.annotations.*; \ No newline at end of file diff --git a/tests/explicit-nulls/neg/nullmarked-separate/J_2.java b/tests/explicit-nulls/neg/nullmarked-separate/J_2.java new file mode 100644 index 000000000000..341d441afde6 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/J_2.java @@ -0,0 +1,61 @@ +package org.jspecify.annotations; + +// Lives in the same package as the (package-private) annotation stubs so it can use them. +// This class is compiled to a classfile first and read back by ClassfileParser. +// The whole class is `@NullMarked`, so unannotated reference types are non-null. +@NullMarked +public class J_2 { + + // `@NullUnmarked` on a constructor makes its parameters nullable again. + @NullUnmarked + public J_2(String s) { + } + + // Unannotated -> non-null String. + public String get() { + return ""; + } + + // Type-use `@Nullable` still reintroduces nullability -> String | Null. + public @Nullable String getNullable() { + return null; + } + + // Unannotated parameter -> non-null String. + public void set(String s) { + } + + // `@NullUnmarked` restores the implicit-nulls default within this method, + // so the parameter is nullable again. + @NullUnmarked + public void unmarkedSet(String s) { + } + + // Annotated with both markers -> behaves as if neither is present, so this method inherits + // the class's `@NullMarked` and its parameter stays non-null. + @NullMarked + @NullUnmarked + public void bothSet(String s) { + } + + // `@NullUnmarked` on a nested class restores the implicit-nulls default for its members. + @NullUnmarked + public static class Inner { + + // Unmarked -> String | Null. + public String innerGet() { + return ""; + } + + // Unmarked parameter -> nullable. + public void innerSet(String s) { + } + + // `@NullMarked` re-marks within the unmarked nested class (nearest scope wins), + // so this result is non-null again. + @NullMarked + public String remarkedGet() { + return ""; + } + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-separate/NullMarked_1.java b/tests/explicit-nulls/neg/nullmarked-separate/NullMarked_1.java new file mode 100644 index 000000000000..1e3dc9f480e9 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/NullMarked_1.java @@ -0,0 +1,10 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's scope marker. Package-private so it can live in a `_1`-suffixed +// file (javac only enforces the public-class/filename match for public types). +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +@interface NullMarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-separate/NullUnmarked_1.java b/tests/explicit-nulls/neg/nullmarked-separate/NullUnmarked_1.java new file mode 100644 index 000000000000..2bd6a4a0a73c --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/NullUnmarked_1.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's scope marker that re-enables implicit nullability. +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +@interface NullUnmarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-separate/Nullable_1.java b/tests/explicit-nulls/neg/nullmarked-separate/Nullable_1.java new file mode 100644 index 000000000000..0326cd8ba01a --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/Nullable_1.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's type-use nullable marker. +@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +@Retention(RetentionPolicy.RUNTIME) +@interface Nullable { +} diff --git a/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala b/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala new file mode 100644 index 000000000000..68af85113d5b --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala @@ -0,0 +1,32 @@ +// Test that `@NullMarked` / `@NullUnmarked` are read from class files. +//> using options -Yno-flexible-types + +import org.jspecify.annotations.J_2 + +// `get` result is non-null in a `@NullMarked` class. +def a(j: J_2): String = j.get() + +// Type-use `@Nullable` still makes the result `String | Null`. +def b(j: J_2): String = j.getNullable() // error + +// `set` parameter is non-null, so `null` is rejected. +def c(j: J_2): Unit = j.set(null) // error + +// `@NullUnmarked` restores implicit nullability, so `null` is accepted here. +def d(j: J_2): Unit = j.unmarkedSet(null) + +// Both markers on `bothSet` behave as if neither: it inherits the class `@NullMarked`, so the +// parameter is non-null and `null` is rejected. +def dd(j: J_2): Unit = j.bothSet(null) // error + +// `@NullUnmarked` constructor: the parameter is nullable, so `null` is accepted. +def e: J_2 = new J_2(null) + +// `@NullUnmarked` nested class: `innerGet` result is `String | Null` again. +def f(i: J_2.Inner): String = i.innerGet() // error + +// `@NullUnmarked` nested class: `innerSet` parameter is nullable, so `null` is accepted. +def g(i: J_2.Inner): Unit = i.innerSet(null) + +// `@NullMarked` re-marks inside the unmarked nested class, so this result is non-null. +def h(i: J_2.Inner): String = i.remarkedGet() diff --git a/tests/explicit-nulls/neg/nullmarked/J.java b/tests/explicit-nulls/neg/nullmarked/J.java new file mode 100644 index 000000000000..f145db9be3ab --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/J.java @@ -0,0 +1,61 @@ +package nullmarked; + +import org.jspecify.annotations.*; + +// The whole class is `@NullMarked`, so unannotated reference types are non-null. +@NullMarked +public class J { + + // `@NullUnmarked` on a constructor makes its parameters nullable again. + @NullUnmarked + public J(String s) { + } + + // Unannotated -> non-null String. + public String get() { + return ""; + } + + // Type-use `@Nullable` still reintroduces nullability -> String | Null. + public @Nullable String getNullable() { + return null; + } + + // Unannotated parameter -> non-null String. + public void set(String s) { + } + + // `@NullUnmarked` restores the implicit-nulls default within this method, + // so the parameter is nullable again. + @NullUnmarked + public void unmarkedSet(String s) { + } + + // Annotated with both markers -> behaves as if neither is present, so this method inherits + // the class's `@NullMarked` and its parameter stays non-null. + @NullMarked + @NullUnmarked + public void bothSet(String s) { + } + + // `@NullUnmarked` on a nested class restores the implicit-nulls default for its members. + @NullUnmarked + public static class Inner { + + // Unmarked -> String | Null. + public String innerGet() { + return ""; + } + + // Unmarked parameter -> nullable. + public void innerSet(String s) { + } + + // `@NullMarked` re-marks within the unmarked nested class (nearest scope wins), + // so this result is non-null again. + @NullMarked + public String remarkedGet() { + return ""; + } + } +} diff --git a/tests/explicit-nulls/neg/nullmarked/NullMarked.java b/tests/explicit-nulls/neg/nullmarked/NullMarked.java new file mode 100644 index 000000000000..fc5ad2ea4099 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/NullMarked.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's scope marker. +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullMarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked/NullUnmarked.java b/tests/explicit-nulls/neg/nullmarked/NullUnmarked.java new file mode 100644 index 000000000000..d8da8874febd --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/NullUnmarked.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's scope marker that re-enables implicit nullability. +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullUnmarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked/Nullable.java b/tests/explicit-nulls/neg/nullmarked/Nullable.java new file mode 100644 index 000000000000..118ca520ea74 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/Nullable.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's type-use nullable marker. +@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Nullable { +} diff --git a/tests/explicit-nulls/neg/nullmarked/S.scala b/tests/explicit-nulls/neg/nullmarked/S.scala new file mode 100644 index 000000000000..a0dfb4c910a8 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/S.scala @@ -0,0 +1,31 @@ +//> using options -Yno-flexible-types + +import nullmarked.J + +// `get` result is non-null in a `@NullMarked` class. +def a(j: J): String = j.get() + +// Type-use `@Nullable` still makes the result `String | Null`. +def b(j: J): String = j.getNullable() // error + +// `set` parameter is non-null, so `null` is rejected. +def c(j: J): Unit = j.set(null) // error + +// `@NullUnmarked` restores implicit nullability, so `null` is accepted here. +def d(j: J): Unit = j.unmarkedSet(null) + +// Both markers on `bothSet` behave as if neither: it inherits the class `@NullMarked`, so the +// parameter is non-null and `null` is rejected. +def dd(j: J): Unit = j.bothSet(null) // error + +// `@NullUnmarked` constructor: the parameter is nullable, so `null` is accepted. +def e: J = new J(null) + +// `@NullUnmarked` nested class: `innerGet` result is `String | Null` again. +def f(i: J.Inner): String = i.innerGet() // error + +// `@NullUnmarked` nested class: `innerSet` parameter is nullable, so `null` is accepted. +def g(i: J.Inner): Unit = i.innerSet(null) + +// `@NullMarked` re-marks inside the unmarked nested class, so this result is non-null. +def h(i: J.Inner): String = i.remarkedGet()