diff --git a/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java b/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java
index 68d1c169..2c644561 100644
--- a/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java
+++ b/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java
@@ -266,6 +266,19 @@ public interface LoomGradleExtensionAPI {
boolean areEnvironmentSourceSetsSplit();
+ /**
+ * When enabled, Loom remaps JSR {@code Nullable}, {@code Nonnull}, and {@code Immutable} annotations to their JetBrains counterparts in the Minecraft JAR.
+ *
+ *
When disabled, Loom keeps JSR annotations as-is, and remaps any JetBrains {@code Nullable}, {@code NotNull}, and {@code Unmodifiable} annotations to their JSR counterparts in the Minecraft JAR.
+ *
+ *
This has no effect on Minecraft versions that solely use JSpecify annotations.
+ *
+ *
Default: true
+ *
+ * @return the property controlling the remapping of JSR annotations
+ */
+ Property getRemapJsrAnnotationsToJetBrains();
+
Property getRuntimeOnlyLog4j();
Property getSplitModDependencies();
diff --git a/src/main/java/net/fabricmc/loom/configuration/CompileConfiguration.java b/src/main/java/net/fabricmc/loom/configuration/CompileConfiguration.java
index 901c1337..8add7c42 100644
--- a/src/main/java/net/fabricmc/loom/configuration/CompileConfiguration.java
+++ b/src/main/java/net/fabricmc/loom/configuration/CompileConfiguration.java
@@ -61,6 +61,7 @@ import net.fabricmc.loom.build.mixin.KaptApInvoker;
import net.fabricmc.loom.build.mixin.ScalaApInvoker;
import net.fabricmc.loom.configuration.accesswidener.AccessWidenerJarProcessor;
import net.fabricmc.loom.configuration.ifaceinject.InterfaceInjectionProcessor;
+import net.fabricmc.loom.configuration.processors.JsrAnnotationRemapperProcessor;
import net.fabricmc.loom.configuration.processors.MinecraftJarProcessorManager;
import net.fabricmc.loom.configuration.processors.ModJavadocProcessor;
import net.fabricmc.loom.configuration.processors.speccontext.DebofConfiguration;
@@ -241,6 +242,10 @@ public abstract class CompileConfiguration implements Runnable {
if (interfaceInjection.isEnabled()) {
extension.addMinecraftJarProcessor(InterfaceInjectionProcessor.class, "fabric-loom:interface-inject", interfaceInjection.getEnableDependencyInterfaceInjection().get());
}
+
+ if (!extension.getRemapJsrAnnotationsToJetBrains().get()) {
+ extension.addMinecraftJarProcessor(JsrAnnotationRemapperProcessor.class, "fabric-loom:jsr-annotations");
+ }
}
private void setupMixinAp(MixinExtension mixin) {
diff --git a/src/main/java/net/fabricmc/loom/configuration/processors/JsrAnnotationRemapperProcessor.java b/src/main/java/net/fabricmc/loom/configuration/processors/JsrAnnotationRemapperProcessor.java
new file mode 100644
index 00000000..08ba51c2
--- /dev/null
+++ b/src/main/java/net/fabricmc/loom/configuration/processors/JsrAnnotationRemapperProcessor.java
@@ -0,0 +1,88 @@
+/*
+ * This file is part of fabric-loom, licensed under the MIT License (MIT).
+ *
+ * Copyright (c) 2025 FabricMC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package net.fabricmc.loom.configuration.processors;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import org.jspecify.annotations.Nullable;
+
+import net.fabricmc.loom.api.processor.MinecraftJarProcessor;
+import net.fabricmc.loom.api.processor.ProcessorContext;
+import net.fabricmc.loom.api.processor.SpecContext;
+import net.fabricmc.loom.util.TinyRemapperLoggerAdapter;
+import net.fabricmc.tinyremapper.IMappingProvider;
+import net.fabricmc.tinyremapper.OutputConsumerPath;
+import net.fabricmc.tinyremapper.TinyRemapper;
+
+public class JsrAnnotationRemapperProcessor implements MinecraftJarProcessor {
+ private static final Map JETBRAINS_TO_JSR = Map.of(
+ "org/jetbrains/annotations/Nullable", "javax/annotation/Nullable",
+ "org/jetbrains/annotations/NotNull", "javax/annotation/Nonnull",
+ "org/jetbrains/annotations/Unmodifiable", "javax/annotation/concurrent/Immutable"
+ );
+
+ private final String name;
+
+ @Inject
+ public JsrAnnotationRemapperProcessor(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public @Nullable Spec buildSpec(SpecContext context) {
+ return new Spec(JETBRAINS_TO_JSR);
+ }
+
+ @Override
+ public void processJar(Path jar, Spec spec, ProcessorContext context) throws IOException {
+ TinyRemapper tinyRemapper = TinyRemapper.newRemapper(TinyRemapperLoggerAdapter.INSTANCE)
+ .withMappings(spec.getMappings())
+ .build();
+
+ try (OutputConsumerPath outputConsumer = new OutputConsumerPath.Builder(jar).build()) {
+ tinyRemapper.readInputs(jar);
+ tinyRemapper.apply(outputConsumer);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to remap JAR " + jar + " with mapping " + spec.annotationMapping(), e);
+ } finally {
+ tinyRemapper.finish();
+ }
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ public record Spec(Map annotationMapping) implements MinecraftJarProcessor.Spec {
+ public IMappingProvider getMappings() {
+ return out -> annotationMapping.forEach(out::acceptClass);
+ }
+ }
+}
diff --git a/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java b/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java
index 48cb1a52..400fff15 100644
--- a/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java
+++ b/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java
@@ -96,6 +96,7 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA
protected final Property intermediary;
protected final Property intermediateMappingsProvider;
private final Property productionNamespace;
+ private final Property remapJsrAnnotationsToJetBrains;
private final Property runtimeOnlyLog4j;
private final Property splitModDependencies;
private final Property> minecraftJarConfiguration;
@@ -180,6 +181,9 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA
this.accessWidener.finalizeValueOnRead();
this.getGameJarProcessors().finalizeValueOnRead();
+ this.remapJsrAnnotationsToJetBrains = project.getObjects().property(Boolean.class).convention(true);
+ this.remapJsrAnnotationsToJetBrains.finalizeValueOnRead();
+
this.runtimeOnlyLog4j = project.getObjects().property(Boolean.class).convention(false);
this.runtimeOnlyLog4j.finalizeValueOnRead();
@@ -406,6 +410,11 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA
return minecraftJarConfiguration;
}
+ @Override
+ public Property getRemapJsrAnnotationsToJetBrains() {
+ return remapJsrAnnotationsToJetBrains;
+ }
+
@Override
public Property getRuntimeOnlyLog4j() {
return runtimeOnlyLog4j;
diff --git a/src/test/groovy/net/fabricmc/loom/test/integration/JsrAnnotationsTest.groovy b/src/test/groovy/net/fabricmc/loom/test/integration/JsrAnnotationsTest.groovy
new file mode 100644
index 00000000..c382b205
--- /dev/null
+++ b/src/test/groovy/net/fabricmc/loom/test/integration/JsrAnnotationsTest.groovy
@@ -0,0 +1,71 @@
+/*
+ * This file is part of fabric-loom, licensed under the MIT License (MIT).
+ *
+ * Copyright (c) 2025 FabricMC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package net.fabricmc.loom.test.integration
+
+import java.nio.charset.StandardCharsets
+
+import spock.lang.Specification
+
+import net.fabricmc.loom.test.util.GradleProjectTestTrait
+import net.fabricmc.loom.util.ZipUtils
+
+import static net.fabricmc.loom.test.LoomTestConstants.PRE_RELEASE_GRADLE
+import static org.gradle.testkit.runner.TaskOutcome.SUCCESS
+
+class JsrAnnotationsTest extends Specification implements GradleProjectTestTrait {
+ static final String MAPPINGS = "21w13a-net.fabricmc.yarn.21w13a.21w13a+build.30-v2"
+ static final boolean REMAP_JSR_DEFAULT = true
+
+ def "Remap JSR annotations to JetBrains #remapJsrAnnotations"() {
+ setup:
+ def gradle = gradleProject(project: "unpick", version: PRE_RELEASE_GRADLE)
+ gradle.buildGradle << """
+ loom {
+ remapJsrAnnotationsToJetBrains = ${remapJsrAnnotations}
+ }
+ """.stripIndent()
+ when:
+ def result = gradle.run(tasks: [
+ "genSourcesWithVineflower",
+ "--info"
+ ])
+ def source = getClassSource(gradle, "net/minecraft/util/annotation/MethodsReturnNonnullByDefault.java", remapJsrAnnotations != REMAP_JSR_DEFAULT)
+
+ then:
+ result.task(":genSourcesWithVineflower").outcome == SUCCESS
+ source.contains(remapJsrAnnotations ? "@NotNull" : "@Nonnull")
+ source.contains(remapJsrAnnotations ? "import org.jetbrains.annotations.NotNull;" : "import javax.annotation.Nonnull;")
+ !source.contains(remapJsrAnnotations ? "@Nonnull" : "@NotNull")
+ !source.contains(remapJsrAnnotations ? "import javax.annotation.Nonnull;" : "import org.jetbrains.annotations.NotNull;")
+
+ where:
+ [remapJsrAnnotations] << [[true, false]].combinations()
+ }
+
+ private static String getClassSource(GradleProject gradle, String classname, boolean local, String mappings = MAPPINGS) {
+ File sourcesJar = local ? gradle.getGeneratedLocalSources(mappings) : gradle.getGeneratedSources(mappings)
+ return new String(ZipUtils.unpack(sourcesJar.toPath(), classname), StandardCharsets.UTF_8)
+ }
+}