diff --git a/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java b/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java index 97f0e7f5..6d8aea1e 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java @@ -1,17 +1,25 @@ package net.neoforged.moddevgradle.internal; +import java.net.URI; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; import net.neoforged.minecraftdependencies.MinecraftDependenciesPlugin; import net.neoforged.moddevgradle.dsl.ModDevExtension; import net.neoforged.moddevgradle.dsl.ModdingVersionSettings; import net.neoforged.moddevgradle.dsl.NeoForgeExtension; import net.neoforged.moddevgradle.internal.jarjar.JarJarPlugin; import net.neoforged.moddevgradle.internal.utils.VersionCapabilitiesInternal; +import net.neoforged.nfrtgradle.NeoFormRuntimeExtension; import net.neoforged.nfrtgradle.NeoFormRuntimePlugin; import org.gradle.api.InvalidUserCodeException; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.artifacts.ModuleDependency; import org.gradle.api.plugins.JavaLibraryPlugin; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -75,9 +83,19 @@ public void enable( var configurations = project.getConfigurations(); - var dependencies = neoForge != null ? ModdingDependencies.create(neoForge, neoForgeNotation, neoForm, neoFormNotation, versionCapabilities) + var dependencies = neoForge != null ? ModdingDependencies.create( + neoForge, + neoForgeNotation, + neoForm, + neoFormNotation, + versionCapabilities) : ModdingDependencies.createVanillaOnly(neoForm, neoFormNotation); + // Always apply at least the stable baseline filter to the NeoForged + // repository. When a NeoForge version is selected we also discover + // additional game-library modules from its metadata. + populateNeoForgeRepositoryFilter(project, neoForgeVersion); + ArtifactNamingStrategy artifactNamingStrategy; // It's helpful to be able to differentiate the Vanilla jar and the NeoForge jar in classic multiloader setups. if (neoForge == null) { @@ -106,4 +124,93 @@ public void enable( artifacts, extension.getRuns()); } + + // 10-second timeout for the .module HTTP requests (both connect and read). + private static final int MODULE_FETCH_TIMEOUT_MS = 10_000; + + /** + * Discovers additional modules from the Gradle Module Metadata of the selected + * NeoForge version (if any) and the NeoForm Runtime, then applies the content + * filter to the NeoForge repository. + *

+ * When the NeoForged repository was configured at the settings level there is + * no project-scoped repository to apply the filter to — this method returns + * early without touching the network. + *

+ * Dynamic modules are collected in a local set and passed directly to + * {@link NeoForgedRepositoryFilter#filter} — no static mutable state is shared + * across projects, which is safe with parallel project configuration. + */ + private static void populateNeoForgeRepositoryFilter(Project project, + @Nullable String neoForgeVersion) { + // When the repository is configured at the settings level there is no + // project-scoped repository to install the filter on; the settings-level + // filter is already in place and cannot be augmented per-project. + if (RepositoriesPlugin.getNeoForgeRepository(project) == null) { + return; + } + + var dynamicModules = new HashSet(); + + if (neoForgeVersion != null) { + var depPattern = Pattern.compile("\"group\":\\s*\"([^\"]+)\",\\s*\"module\":\\s*\"([^\"]+)\""); + fetchModuleDependencies(project, "net/neoforged/neoforge/" + neoForgeVersion + + "/neoforge-" + neoForgeVersion + ".module", depPattern, dynamicModules); + // NFRT metadata fetch uses the same pattern. + try { + var nfrtVersion = NeoFormRuntimeExtension.getVersion(project); + fetchModuleDependencies(project, "net/neoforged/neoform-runtime/" + nfrtVersion + + "/neoform-runtime-" + nfrtVersion + ".module", depPattern, dynamicModules); + } catch (Exception e) { + LOG.warn("Failed to resolve NFRT version for dynamic filter discovery: {}", e.getMessage()); + } + } + + // Apply the content filter now — before any dependency resolution uses + // the NeoForge repository. Each enable() call passes its own local set, + // so parallel project configuration is safe. + RepositoriesPlugin.applyContentFilter(project, dynamicModules); + } + + /** + * Downloads a single {@code .module} file from the NeoForged Maven and extracts + * {@code group:module} pairs from it. + *

+ * Uses {@link URLConnection} directly rather than Gradle dependency resolution + * because Gradle locks the repository content descriptor on first use, which + * would prevent us from installing the content filter afterward. Respects + * {@code --offline} and applies explicit connect/read timeouts. + */ + private static void fetchModuleDependencies(Project project, String path, + Pattern depPattern, Set dynamicModules) { + if (project.getGradle().getStartParameter().isOffline()) { + return; + } + + try { + var moduleUrl = URI.create( + "https://maven.neoforged.net/releases/" + path).toURL(); + + var connection = moduleUrl.openConnection(); + connection.setConnectTimeout(MODULE_FETCH_TIMEOUT_MS); + connection.setReadTimeout(MODULE_FETCH_TIMEOUT_MS); + + try (var stream = connection.getInputStream()) { + var content = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + var matcher = depPattern.matcher(content); + while (matcher.find()) { + var group = matcher.group(1); + var module = matcher.group(2); + if ("*".equals(group) || "*".equals(module)) { + continue; + } + dynamicModules.add(group + ":" + module); + } + } + } catch (Exception e) { + LOG.warn( + "Failed to discover dependencies from {}: {}", + path, e.getMessage()); + } + } } diff --git a/src/main/java/net/neoforged/moddevgradle/internal/NeoForgedRepositoryFilter.java b/src/main/java/net/neoforged/moddevgradle/internal/NeoForgedRepositoryFilter.java new file mode 100644 index 00000000..f16eff1b --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/NeoForgedRepositoryFilter.java @@ -0,0 +1,227 @@ +package net.neoforged.moddevgradle.internal; + +import java.util.Set; +import org.gradle.api.artifacts.repositories.RepositoryContentDescriptor; + +/** + * Controls which modules Gradle may resolve from the NeoForged Maven. + *

+ * The NeoForged Maven mirrors many Maven Central artifacts. Without a content filter, + * Gradle could resolve arbitrary third-party artifacts from it. + *

+ * This filter is built from two layers: + *

+ */ +public class NeoForgedRepositoryFilter { + /** + * All modules currently known to be hosted on the NeoForged Maven. + * When the selected NeoForge version pulls in a library that is not yet listed here, + * the dynamic discovery path in {@link net.neoforged.moddevgradle.internal.ModDevPlugin} + * adds it at configuration time. + */ + private static final String[][] STABLE_MODULES = { + // --- net.neoforged sub-groups --- + { "net.neoforged.accesstransformers", "at-modlauncher" }, + { "net.neoforged.accesstransformers", "at-parser" }, + { "net.neoforged.fancymodloader", "earlydisplay" }, + { "net.neoforged.fancymodloader", "junit-fml" }, + { "net.neoforged.fancymodloader", "loader" }, + { "net.neoforged.installertools", "binarypatcher" }, + { "net.neoforged.installertools", "cli-utils" }, + { "net.neoforged.installertools", "installertools" }, + { "net.neoforged.javadoctor", "gson-io" }, + { "net.neoforged.javadoctor", "spec" }, + { "net.neoforged.jst", "jst-cli-bundle" }, + // --- main net.neoforged group --- + { "net.neoforged", "AutoRenamingTool" }, + { "net.neoforged", "DevLaunch" }, + { "net.neoforged", "JarJarFileSystems" }, + { "net.neoforged", "JarJarMetadata" }, + { "net.neoforged", "JarJarSelector" }, + { "net.neoforged", "accesstransformers" }, + { "net.neoforged", "bus" }, + { "net.neoforged", "coremods" }, + { "net.neoforged", "mergetool" }, + { "net.neoforged", "minecraft-dependencies" }, + { "net.neoforged", "neoforge" }, + { "net.neoforged", "neoform" }, + { "net.neoforged", "neoform-runtime" }, + { "net.neoforged", "srgutils" }, + { "net.neoforged", "testframework" }, + // --- modding toolchain --- + { "cpw.mods", "bootstraplauncher" }, + { "cpw.mods", "modlauncher" }, + { "cpw.mods", "securejarhandler" }, + { "net.minecraftforge", "mergetool" }, + { "net.minecraftforge", "srgutils" }, + { "net.minecrell", "terminalconsoleappender" }, + { "net.covers1624", "DevLogin" }, + { "net.covers1624", "Quack" }, + { "io.codechicken", "DiffPatch" }, + { "io.github.llamalad7", "mixinextras-neoforge" }, + { "net.fabricmc", "sponge-mixin" }, + { "de.siegmar", "fastcsv" }, + { "com.machinezoo.noexception", "noexception" }, + { "net.jodah", "typetools" }, + { "com.nothome", "javaxdelta" }, + { "trove", "trove" }, + // --- NeoForge-maintained library forks --- + { "com.electronwill.night-config", "core" }, + { "com.electronwill.night-config", "toml" }, + // --- Transitive dependencies of NFRT external tools --- + { "it.unimi.dsi", "fastutil" }, + { "org.apache.commons", "commons-compress" }, + { "org.apache.commons", "commons-lang3" }, + { "org.apache.commons", "commons-parent" }, + { "org.tukaani", "xz" }, + { "org.ow2.asm", "asm" }, + { "org.ow2.asm", "asm-analysis" }, + { "org.ow2.asm", "asm-commons" }, + { "org.ow2.asm", "asm-tree" }, + { "org.ow2.asm", "asm-util" }, + { "org.ow2", "ow2" }, + { "org.lz4", "lz4-java" }, + { "org.jcraft", "jorbis" }, + // --- Game libraries & their transitive dependencies --- + // Transitive dependencies of common game libraries are listed explicitly + // because the dynamic discovery only sees direct dependencies (a single + // module-metadata download), not the full transitive tree. + { "ca.weblite", "java-objc-bridge" }, + { "com.fasterxml.jackson.core", "jackson-annotations" }, + { "com.fasterxml.jackson.core", "jackson-core" }, + { "com.fasterxml.jackson", "jackson-base" }, + { "com.fasterxml.jackson", "jackson-bom" }, + { "com.fasterxml.jackson", "jackson-parent" }, + { "com.fasterxml", "oss-parent" }, + { "com.github.oshi", "oshi-core" }, + { "com.github.oshi", "oshi-parent" }, + { "com.google.code.findbugs", "jsr305" }, + { "com.google.code.gson", "gson" }, + { "com.google.code.gson", "gson-parent" }, + { "com.google.errorprone", "error_prone_annotations" }, + { "com.google.errorprone", "error_prone_parent" }, + { "com.google.guava", "failureaccess" }, + { "com.google.guava", "guava" }, + { "com.google.guava", "guava-parent" }, + { "com.google.guava", "listenablefuture" }, + { "com.google.j2objc", "j2objc-annotations" }, + { "com.ibm.icu", "icu4j" }, + { "com.mojang", "authlib" }, + { "com.mojang", "blocklist" }, + { "com.mojang", "brigadier" }, + { "com.mojang", "datafixerupper" }, + { "com.mojang", "logging" }, + { "com.mojang", "patchy" }, + { "com.mojang", "text2speech" }, + { "commons-codec", "commons-codec" }, + { "commons-io", "commons-io" }, + { "commons-logging", "commons-logging" }, + { "io.fabric8", "kubernetes-client-bom" }, + { "io.netty", "netty-bom" }, + { "io.netty", "netty-buffer" }, + { "io.netty", "netty-codec" }, + { "io.netty", "netty-common" }, + { "io.netty", "netty-handler" }, + { "io.netty", "netty-parent" }, + { "io.netty", "netty-resolver" }, + { "io.netty", "netty-transport" }, + { "io.netty", "netty-transport-classes-epoll" }, + { "io.netty", "netty-transport-native-unix-common" }, + { "jakarta.platform", "jakarta.jakartaee-bom" }, + { "jakarta.platform", "jakartaee-api-parent" }, + { "net.java.dev.jna", "jna" }, + { "net.java.dev.jna", "jna-platform" }, + { "org.antlr", "antlr4-master" }, + { "org.antlr", "antlr4-runtime" }, + { "org.apache.groovy", "groovy-bom" }, + { "org.apache.httpcomponents", "httpclient" }, + { "org.apache.httpcomponents", "httpcomponents-client" }, + { "org.apache.httpcomponents", "httpcomponents-core" }, + { "org.apache.httpcomponents", "httpcomponents-parent" }, + { "org.apache.httpcomponents", "httpcore" }, + { "org.apache.logging.log4j", "log4j" }, + { "org.apache.logging.log4j", "log4j-api" }, + { "org.apache.logging.log4j", "log4j-bom" }, + { "org.apache.logging.log4j", "log4j-core" }, + { "org.apache.logging.log4j", "log4j-slf4j2-impl" }, + { "org.apache.logging", "logging-parent" }, + { "org.apache.maven", "maven" }, + { "org.apache.maven", "maven-artifact" }, + { "org.apache.maven", "maven-parent" }, + { "org.apache", "apache" }, + { "org.apiguardian", "apiguardian-api" }, + { "org.checkerframework", "checker-qual" }, + { "org.codehaus.groovy", "groovy-bom" }, + { "org.codehaus.plexus", "plexus" }, + { "org.codehaus.plexus", "plexus-utils" }, + { "org.commonmark", "commonmark" }, + { "org.commonmark", "commonmark-parent" }, + { "org.eclipse.ee4j", "project" }, + { "org.eclipse.jetty", "jetty-bom" }, + { "org.jetbrains", "annotations" }, + { "org.jline", "jline-parent" }, + { "org.jline", "jline-reader" }, + { "org.jline", "jline-terminal" }, + { "org.joml", "joml" }, + { "org.jspecify", "jspecify" }, + { "org.junit.jupiter", "junit-jupiter" }, + { "org.junit.jupiter", "junit-jupiter-api" }, + { "org.junit.jupiter", "junit-jupiter-engine" }, + { "org.junit.jupiter", "junit-jupiter-params" }, + { "org.junit.platform", "junit-platform-commons" }, + { "org.junit.platform", "junit-platform-engine" }, + { "org.junit.platform", "junit-platform-launcher" }, + { "org.junit", "junit-bom" }, + { "org.lwjgl", "lwjgl" }, + { "org.lwjgl", "lwjgl-bom" }, + { "org.lwjgl", "lwjgl-freetype" }, + { "org.lwjgl", "lwjgl-glfw" }, + { "org.lwjgl", "lwjgl-jemalloc" }, + { "org.lwjgl", "lwjgl-openal" }, + { "org.lwjgl", "lwjgl-opengl" }, + { "org.lwjgl", "lwjgl-stb" }, + { "org.lwjgl", "lwjgl-tinyfd" }, + { "org.mockito", "mockito-bom" }, + { "org.opentest4j", "opentest4j" }, + { "org.slf4j", "slf4j-api" }, + { "org.slf4j", "slf4j-bom" }, + { "org.slf4j", "slf4j-parent" }, + { "org.sonatype.oss", "oss-parent" }, + { "org.springframework", "spring-framework-bom" }, + // --- Other NeoForge-hosted tooling --- + { "org.parchmentmc.data", "parchment-1.21" }, + { "org.vineflower", "vineflower" }, + { "org.openjdk.nashorn", "nashorn-core" }, + { "net.sf.jopt-simple", "jopt-simple" }, + }; + + /** + * Applies the full filter to the given repository content descriptor: stable + * known modules plus caller-supplied dynamically discovered modules. + * + * @param descriptor the repository content descriptor to configure + * @param dynamicModules set of {@code "group:module"} strings discovered at + * configuration time; may be empty but never null + */ + public static void filter(RepositoryContentDescriptor descriptor, Set dynamicModules) { + for (var entry : STABLE_MODULES) { + descriptor.includeModule(entry[0], entry[1]); + } + + for (var coordinate : dynamicModules) { + var parts = coordinate.split(":", 2); + if (parts.length == 2) { + descriptor.includeModule(parts[0], parts[1]); + } + } + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/RepositoriesPlugin.java b/src/main/java/net/neoforged/moddevgradle/internal/RepositoriesPlugin.java index ec296a1a..b85d7091 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/RepositoriesPlugin.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/RepositoriesPlugin.java @@ -1,6 +1,7 @@ package net.neoforged.moddevgradle.internal; import java.net.URI; +import java.util.Set; import net.neoforged.moddevgradle.internal.generated.MojangRepositoryFilter; import org.gradle.api.GradleException; import org.gradle.api.Plugin; @@ -10,22 +11,33 @@ import org.gradle.api.initialization.Settings; import org.gradle.api.invocation.Gradle; import org.gradle.api.plugins.PluginAware; +import org.jetbrains.annotations.Nullable; /** * This plugin acts in different roles depending on where it is applied: * */ public class RepositoriesPlugin implements Plugin { + static final String NEOFORGE_REPO_EXTENSION = "__internal_neoForgeRepository"; + @Override public void apply(PluginAware target) { if (target instanceof Project project) { - applyRepositories(project.getRepositories()); + // Defer content filter so ModDevPlugin.enable() can populate it dynamically + var neoRepo = applyRepositories(project.getRepositories(), false); + project.getExtensions().add(MavenArtifactRepository.class, NEOFORGE_REPO_EXTENSION, neoRepo); } else if (target instanceof Settings settings) { - applyRepositories(settings.getDependencyResolutionManagement().getRepositories()); + // Settings-level: apply content filter immediately since per-project + // dynamic population is not possible for shared repositories + applyRepositories(settings.getDependencyResolutionManagement().getRepositories(), true); settings.getGradle().getPlugins().apply(getClass()); // Add a marker to Gradle } else if (target instanceof Gradle gradle) { // Do nothing @@ -34,7 +46,37 @@ public void apply(PluginAware target) { } } - private void applyRepositories(RepositoryHandler repositories) { + /** + * Returns the NeoForge repository, or {@code null} when the repository was + * configured at the settings level and this project has no local reference. + */ + @Nullable + static MavenArtifactRepository getNeoForgeRepository(Project project) { + var ext = project.getExtensions().findByName(NEOFORGE_REPO_EXTENSION); + return (MavenArtifactRepository) ext; + } + + /** + * Applies the content filter to the NeoForge repository, including the + * caller-supplied dynamically discovered modules. + *

+ * Must be called before any dependency resolution uses this repository — + * Gradle locks the content descriptor on first use. + *

+ * When the repository was configured at the settings level this is a safe + * no-op — the content filter is already installed from {@code apply()}. + * + * @param dynamicModules set of {@code "group:module"} strings discovered at + * configuration time; empty set when none were discovered + */ + static void applyContentFilter(Project project, Set dynamicModules) { + var neoRepo = getNeoForgeRepository(project); + if (neoRepo != null) { + neoRepo.content(descriptor -> NeoForgedRepositoryFilter.filter(descriptor, dynamicModules)); + } + } + + private static MavenArtifactRepository applyRepositories(RepositoryHandler repositories, boolean applyContentFilter) { var mojangMaven = repositories.maven(repo -> { repo.setName("Mojang Minecraft Libraries"); repo.setUrl(URI.create("https://libraries.minecraft.net/")); @@ -53,10 +95,14 @@ private void applyRepositories(RepositoryHandler repositories) { }); sortFirst(repositories, mojangMetaMaven); - repositories.maven(repo -> { + var neoForgeRepo = repositories.maven(repo -> { repo.setName("NeoForged Releases"); repo.setUrl(URI.create("https://maven.neoforged.net/releases/")); + if (applyContentFilter) { + repo.content(descriptor -> NeoForgedRepositoryFilter.filter(descriptor, Set.of())); + } }); + return neoForgeRepo; } private static void sortFirst(RepositoryHandler repositories, MavenArtifactRepository repo) { diff --git a/src/main/java/net/neoforged/nfrtgradle/NeoFormRuntimeExtension.java b/src/main/java/net/neoforged/nfrtgradle/NeoFormRuntimeExtension.java index 80577475..2b9e1244 100644 --- a/src/main/java/net/neoforged/nfrtgradle/NeoFormRuntimeExtension.java +++ b/src/main/java/net/neoforged/nfrtgradle/NeoFormRuntimeExtension.java @@ -24,6 +24,14 @@ public NeoFormRuntimeExtension(Project project) { getLauncherManifestUrl().convention(PropertyUtils.getStringProperty(project, "neoForge.neoFormRuntime.launcherManifestUrl")); } + /** + * Returns the effective NFRT version from the project's extension. + */ + public static String getVersion(Project project) { + var ext = project.getExtensions().getByType(NeoFormRuntimeExtension.class); + return ext.getVersion().get(); + } + /** * Overrides the version of NFRT to use. This is an advanced feature. This plugin will default to a * compatible version. diff --git a/src/test/java/net/neoforged/moddevgradle/internal/ModDevPluginTest.java b/src/test/java/net/neoforged/moddevgradle/internal/ModDevPluginTest.java index 09acc348..c705c220 100644 --- a/src/test/java/net/neoforged/moddevgradle/internal/ModDevPluginTest.java +++ b/src/test/java/net/neoforged/moddevgradle/internal/ModDevPluginTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.HashSet; import java.util.Set; import net.neoforged.moddevgradle.AbstractProjectBuilderTest; import net.neoforged.moddevgradle.dsl.NeoForgeExtension; @@ -11,6 +12,8 @@ import net.neoforged.moddevgradle.internal.utils.VersionCapabilitiesInternal; import org.gradle.api.InvalidUserCodeException; import org.gradle.api.Task; +import org.gradle.api.artifacts.repositories.RepositoryContentDescriptor; +import org.gradle.api.attributes.Attribute; import org.gradle.api.plugins.JavaPluginExtension; import org.gradle.api.tasks.SourceSet; import org.gradle.jvm.toolchain.JavaLanguageVersion; @@ -174,6 +177,160 @@ private void assertContainsModdingCompileDependencies(String version, String con "net.neoforged:neoforge:" + version + "[net.neoforged:neoforge-dependencies]"); } + @Nested + class RepositoryFilter { + @Test + void testFilterIncludesStableModules() { + var descriptor = new RecordingDescriptor(); + NeoForgedRepositoryFilter.filter(descriptor, Set.of()); + + // The stable baseline must include NeoForge's own artifacts. + assertThat(descriptor.included).contains("net.neoforged:neoforge"); + } + + @Test + void testFilterIncludesDynamicModules() { + var descriptor = new RecordingDescriptor(); + var dynamic = Set.of("com.example:new-lib", "org.test:another"); + NeoForgedRepositoryFilter.filter(descriptor, dynamic); + + assertThat(descriptor.included).contains("com.example:new-lib"); + assertThat(descriptor.included).contains("org.test:another"); + } + + @Test + void testFilterIgnoresMalformedDynamicEntries() { + var descriptor = new RecordingDescriptor(); + // Entries without a colon are skipped gracefully. + NeoForgedRepositoryFilter.filter(descriptor, Set.of("malformed")); + + // Stable modules still included; malformed entry did not throw. + assertThat(descriptor.included).isNotEmpty(); + // "malformed" was never passed to includeModule because split(":", 2) + // produced only one part and the length check guarded the call. + } + + @Test + void testNeoForgeRepositoryIsRegisteredAfterEnable() { + extension.setVersion("21.10.48-beta"); + + var neoRepo = RepositoriesPlugin.getNeoForgeRepository(project); + assertThat(neoRepo).isNotNull(); + assertThat(project.getRepositories().stream() + .filter(r -> "NeoForged Releases".equals(r.getName())) + .findFirst()).isPresent(); + } + + @Test + void testNeoForgeRepositoryIsRegisteredAfterVanillaOnlyEnable() { + extension.setNeoFormVersion("1.21.4-20240101.235959"); + + var neoRepo = RepositoriesPlugin.getNeoForgeRepository(project); + assertThat(neoRepo).isNotNull(); + assertThat(project.getRepositories().stream() + .filter(r -> "NeoForged Releases".equals(r.getName())) + .findFirst()).isPresent(); + } + + @Test + void testApplyContentFilterIsNoOpWhenRepositoryNotOnProject() { + var freshProject = ProjectBuilder.builder().build(); + // Must not throw, even though there is no NeoForge repository extension. + RepositoriesPlugin.applyContentFilter(freshProject, Set.of()); + } + + @Test + void testApplyContentFilterDoesNotThrowWithEmptyDynamicSet() { + extension.setVersion("21.10.48-beta"); + var neoRepo = RepositoriesPlugin.getNeoForgeRepository(project); + assertThat(neoRepo).isNotNull(); + // Applying the filter with an empty dynamic set is valid (e.g. offline mode). + RepositoriesPlugin.applyContentFilter(project, Set.of()); + } + + @Test + void testParallelEnablesUseIsolatedDynamicSets() { + // Simulate two projects enabling modding concurrently — each call + // to populateNeoForgeRepositoryFilter passes its own local HashSet, + // so there is no shared mutable state between them. + extension.setVersion("21.10.48-beta"); + + var project2 = ProjectBuilder.builder().build(); + project2.getPlugins().apply(ModDevPlugin.class); + var ext2 = ExtensionUtils.getExtension(project2, "neoForge", NeoForgeExtension.class); + var java2 = ExtensionUtils.getExtension(project2, "java", JavaPluginExtension.class); + java2.getToolchain().getLanguageVersion().set(JavaLanguageVersion.current()); + ext2.setVersion("21.0.133-beta"); + + // Both projects must have their NeoForge repository available without + // cross-contamination of dynamic module sets. + assertThat(RepositoriesPlugin.getNeoForgeRepository(project)).isNotNull(); + assertThat(RepositoriesPlugin.getNeoForgeRepository(project2)).isNotNull(); + } + + /** + * A minimal {@link RepositoryContentDescriptor} that records every + * {@code includeModule} call so tests can assert filter behavior. + */ + static class RecordingDescriptor implements RepositoryContentDescriptor { + final Set included = new HashSet<>(); + + @Override + public void includeModule(String group, String name) { + included.add(group + ":" + name); + } + + // Remaining methods are unused by NeoForgedRepositoryFilter; stub them out. + @Override + public void includeGroup(String group) {} + + @Override + public void includeGroupAndSubgroups(String groupPrefix) {} + + @Override + public void includeGroupByRegex(String groupRegex) {} + + @Override + public void includeModuleByRegex(String groupRegex, String nameRegex) {} + + @Override + public void includeVersion(String group, String name, String version) {} + + @Override + public void includeVersionByRegex(String groupRegex, String nameRegex, String versionRegex) {} + + @Override + public void excludeGroup(String group) {} + + @Override + public void excludeGroupAndSubgroups(String groupPrefix) {} + + @Override + public void excludeGroupByRegex(String groupRegex) {} + + @Override + public void excludeModule(String group, String name) {} + + @Override + public void excludeModuleByRegex(String groupRegex, String nameRegex) {} + + @Override + public void excludeVersion(String group, String name, String version) {} + + @Override + public void excludeVersionByRegex(String groupRegex, String nameRegex, String versionRegex) {} + + @Override + public void onlyForConfigurations(String... configurationNames) {} + + @Override + public void notForConfigurations(String... configurationNames) {} + + @Override + public void onlyForAttribute(Attribute attribute, T... validValues) {} + } + } + private void assertContainsModdingRuntimeDependencies(String version, String configurationName) { var configuration = project.getConfigurations().getByName(configurationName);