Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions compiler/src/dotty/tools/dotc/core/Definitions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
olhotak marked this conversation as resolved.

// convenient one-parameter method types
def methOfAny(tp: Type): MethodType = MethodType(List(AnyType), tp)
def methOfAnyVal(tp: Type): MethodType = MethodType(List(AnyValType), tp)
Expand Down
60 changes: 53 additions & 7 deletions compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the document (https://jspecify.dev/docs/api/org/jspecify/annotations/NullMarked.html):

@NullMarked and @NullUnmarked ... To apply these annotations to an entire (single) package, create a package-info.java file and annotate the package declaration there. This annotation has no effect on "subpackages".

We may want to stop the iteration at package level.


/** 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] =
Comment thread
HarrisL2 marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to also handle module-info?

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)

Expand All @@ -115,22 +154,29 @@ 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
* coming from Scala code compiled without explicit nulls, this adds `| Null` or `FlexibleType` in the
* 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw the spec has some special rules for wildcards and type parameters. Are they supported by this PR? If not, I'm not sure if we want to support them?

val javaDefined: Boolean,
val ambient: NullMode,
var state: NullMapState
)(using Context) extends TypeMap:

Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions tests/explicit-nulls/neg/nullmarked-packageinfo-separate/J.java
Original file line number Diff line number Diff line change
@@ -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
Comment thread
HarrisL2 marked this conversation as resolved.
public void unmarkedSet(String s) {
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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 {
}
32 changes: 32 additions & 0 deletions tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala
Comment thread
HarrisL2 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions tests/explicit-nulls/neg/nullmarked-packageinfo-separate/U.java
Original file line number Diff line number Diff line change
@@ -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 "";
}
}
Original file line number Diff line number Diff line change
@@ -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.*;
26 changes: 26 additions & 0 deletions tests/explicit-nulls/neg/nullmarked-packageinfo/J.java
Original file line number Diff line number Diff line change
@@ -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) {
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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 {
}
8 changes: 8 additions & 0 deletions tests/explicit-nulls/neg/nullmarked-packageinfo/Nullable.java
Original file line number Diff line number Diff line change
@@ -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 {
}
29 changes: 29 additions & 0 deletions tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala
Comment thread
HarrisL2 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading