diff --git a/build.gradle.kts b/build.gradle.kts index b263b7d..fc3e5eb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,9 @@ repositories { } dependencies { + testImplementation(libs.aether.api) + testImplementation(libs.snakeyaml) + testImplementation(libs.paper.api) testImplementation(libs.junit4) testImplementation(libs.sqlite) testImplementation(libs.h2) diff --git a/docs/runtime-libraries.md b/docs/runtime-libraries.md new file mode 100644 index 0000000..a9e460f --- /dev/null +++ b/docs/runtime-libraries.md @@ -0,0 +1,41 @@ +# Runtime library loading + +The Paper `SyntaxLoader` uses the same manifest structure as PunisherX: +`repositories` maps repository IDs to URLs, and `libraries` contains either +`group:artifact:version` strings or mappings with `coordinate` and `repository`. +Gradle still expands versions from the existing build configuration. + +`central: "MAVEN_CENTRAL_DEFAULT_MIRROR"` is resolved by the loader through +Paper's `MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR`, including the server's +configured mirror. Do not use the obsolete Paper `/repository/maven-central/` URL. + +Libraries without an explicit repository use Central, except SyntaxDevTeam +groups (`pl.syntaxdevteam` and its subgroups), which use the snapshots or releases +repository according to their version. An explicit `repository` always wins. +Each selected repository gets its own resolver with Central available for +transitive dependencies. Unknown repository IDs, invalid entries, missing manifests and +unexpanded placeholders stop bootstrap with a descriptive error. + +Example: + +```yaml +repositories: + central: "MAVEN_CENTRAL_DEFAULT_MIRROR" + syntaxdevteam-snapshots: "https://nexus.syntaxdevteam.pl/repository/maven-snapshots/" + syntaxdevteam-releases: "https://nexus.syntaxdevteam.pl/repository/maven-releases/" + custom: "https://example.com/maven/" +libraries: + - com.github.ben-manes.caffeine:caffeine:3.2.4 + - coordinate: com.example:library:1.0.0 + repository: custom +``` + +This change preserves library versions and the Paper/Folia plugin lifecycle. +It only changes dependency bootstrap and manifest validation. + +## Verification — 2026-10-11 + +`test build` passed (240 tests, including seven loader regression tests; +no failures or errors). The packaged Paper JAR contains the expanded manifest +with repository assignments and no obsolete Central URL or unresolved versions. +A live Paper/Folia server start was not repeated. diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.kt index 9c555ac..30e2030 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.kt @@ -7,46 +7,101 @@ import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.graph.Dependency import org.eclipse.aether.repository.RemoteRepository import org.yaml.snakeyaml.Yaml -import java.io.IOException import java.io.InputStream +/** Reads repository assignments from the same runtime manifest format as PunisherX. */ @Suppress("UnstableApiUsage", "unused") class SyntaxLoader : PluginLoader { override fun classloader(pluginClasspath: PluginClasspathBuilder) { - val resolver = MavenLibraryResolver() - - resolveLibraries().map { DefaultArtifact(it) } - .forEach { artifact -> resolver.addDependency(Dependency(artifact, null)) } - - resolver.addRepository(RemoteRepository.Builder("syntaxdevteam-snapshots", "default", "https://nexus.syntaxdevteam.pl/repository/maven-snapshots/").build()) - resolver.addRepository(RemoteRepository.Builder("syntaxdevteam-releases", "default", "https://nexus.syntaxdevteam.pl/repository/maven-releases/").build()) - resolver.addRepository(RemoteRepository.Builder("paper", "default", "https://repo.papermc.io/repository/maven-public/").build()) - resolver.addRepository(RemoteRepository.Builder("central-mirror", "default", "https://repo.papermc.io/repository/maven-central/").build()) - resolver.addRepository(RemoteRepository.Builder("jitpack", "default", "https://jitpack.io/").build()) - resolver.addRepository(RemoteRepository.Builder("codemc-snapshots", "default", "https://repo.codemc.io/repository/maven-snapshots/").build()) - pluginClasspath.addLibrary(resolver) + val config = try { + val input = SyntaxLoader::class.java.classLoader.getResourceAsStream("paper-libraries.yml") + ?: error("paper-libraries.yml not found in plugin JAR") + input.use(::readLibraryConfig) + } catch (failure: Exception) { + throw IllegalStateException("Cannot configure PlotsX runtime libraries from paper-libraries.yml", failure) + } + val resolvers = linkedMapOf() + for (library in config.libraries) { + val repositoryId = selectRepository(library, config) + val resolver = resolvers.getOrPut(repositoryId) { + MavenLibraryResolver().apply { + repositoriesFor(repositoryId, config).forEach(::addRepository) + } + } + resolver.addDependency(Dependency(DefaultArtifact(library.coordinate), null)) + } + resolvers.values.forEach(pluginClasspath::addLibrary) } - private fun resolveLibraries(): List { - return try { - readLibraryListFromYaml() - } catch (e: IOException) { - e.printStackTrace() - emptyList() + internal fun readLibraryConfig(input: InputStream): LibraryConfig { + val document = Yaml().load(input) as? Map<*, *> + ?: error("paper-libraries.yml must contain a mapping") + val repositoryData = document["repositories"] as? Map<*, *> + ?: error("paper-libraries.yml must define repositories") + val repositories = linkedMapOf() + for ((key, value) in repositoryData) { + val id = (key as? String)?.trim().orEmpty() + val configuredUrl = (value as? String)?.trim().orEmpty() + require(id.isNotEmpty() && configuredUrl.isNotEmpty()) { "Invalid repository entry: $key" } + val url = if (configuredUrl == "MAVEN_CENTRAL_DEFAULT_MIRROR") { + MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR + } else { + configuredUrl + } + require(url.startsWith("https://") || url.startsWith("http://")) { "Invalid repository URL for $id" } + repositories[id] = RemoteRepository.Builder(id, "default", url).build() + } + require(repositories.containsKey("central")) { "Missing central repository in paper-libraries.yml" } + val libraryData = document["libraries"] as? List<*> + ?: error("paper-libraries.yml must define libraries") + require(libraryData.isNotEmpty()) { "No runtime libraries defined in paper-libraries.yml" } + val libraries = libraryData.mapIndexed { index, entry -> + val library = when (entry) { + is String -> LibrarySpec(entry.trim()) + is Map<*, *> -> { + val coordinate = entry["coordinate"] as? String + ?: error("Missing coordinate in library entry ${index + 1}") + val repository = entry["repository"] + require(repository == null || repository is String) { "Invalid repository in library entry ${index + 1}" } + LibrarySpec(coordinate.trim(), repository?.trim()) + } + else -> error("Invalid library entry ${index + 1}") + } + require(library.coordinate.isNotEmpty() && !library.coordinate.contains("\${")) { + "Invalid or unexpanded library coordinate: ${library.coordinate}" + } + DefaultArtifact(library.coordinate) + library + } + return LibraryConfig(repositories, libraries).also { config -> + config.libraries.forEach { selectRepository(it, config) } } } - private fun readLibraryListFromYaml(): List { - val yaml = Yaml() - val inputStream: InputStream? = SyntaxLoader::class.java.classLoader.getResourceAsStream("paper-libraries.yml") - - if (inputStream == null) { - System.err.println("paper-libraries.yml not found in the classpath.") - return emptyList() + internal fun selectRepository(library: LibrarySpec, config: LibraryConfig): String { + val repositoryId = library.repository ?: when { + library.coordinate.substringBefore(':').let { it == "pl.syntaxdevteam" || it.startsWith("pl.syntaxdevteam.") } -> + if (library.coordinate.endsWith("-SNAPSHOT")) "syntaxdevteam-snapshots" else "syntaxdevteam-releases" + else -> "central" } + require(config.repositories.containsKey(repositoryId)) { + "Unknown repository '$repositoryId' for ${library.coordinate}" + } + return repositoryId + } - val data: Map> = yaml.load(inputStream) - return data["libraries"] ?: emptyList() + internal fun repositoriesFor(repositoryId: String, config: LibraryConfig): List { + val selected = config.repositories.getValue(repositoryId) + // The selected repository also needs Central for transitive dependencies. + return if (repositoryId == "central") listOf(selected) + else listOf(selected, config.repositories.getValue("central")) } + + internal data class LibraryConfig( + val repositories: Map, + val libraries: List + ) + + internal data class LibrarySpec(val coordinate: String, val repository: String? = null) } diff --git a/src/main/resources/paper-libraries.yml b/src/main/resources/paper-libraries.yml index 02e296b..71c99b3 100644 --- a/src/main/resources/paper-libraries.yml +++ b/src/main/resources/paper-libraries.yml @@ -1,3 +1,11 @@ +repositories: + syntaxdevteam-snapshots: "https://nexus.syntaxdevteam.pl/repository/maven-snapshots/" + syntaxdevteam-releases: "https://nexus.syntaxdevteam.pl/repository/maven-releases/" + central: "MAVEN_CENTRAL_DEFAULT_MIRROR" + paper: "https://repo.papermc.io/repository/maven-public/" + jitpack: "https://jitpack.io/" + codemc-snapshots: "https://repo.codemc.io/repository/maven-snapshots/" + libraries: - pl.syntaxdevteam:syntaxcore:${syntaxcoreVersion} - pl.syntaxdevteam:messageHandler-paper:${messagehandlerVersion} diff --git a/src/test/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoaderTest.kt b/src/test/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoaderTest.kt new file mode 100644 index 0000000..c9f25dc --- /dev/null +++ b/src/test/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoaderTest.kt @@ -0,0 +1,116 @@ +package pl.syntaxdevteam.plotsx.loader + +import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver +import org.junit.Test +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import java.io.ByteArrayInputStream + +class SyntaxLoaderTest { + private val loader = SyntaxLoader() + private val repositories = """ + repositories: + central: "MAVEN_CENTRAL_DEFAULT_MIRROR" + syntaxdevteam-snapshots: "https://example.com/snapshots/" + syntaxdevteam-releases: "https://example.com/releases/" + custom: "https://example.com/custom/" + """.trimIndent() + + private fun read(libraries: String) = loader.readLibraryConfig( + ByteArrayInputStream((repositories + "\n" + libraries.trimIndent()).toByteArray()) + ) + + @Test + fun routesCentralAndSyntaxDevTeamLibraries() { + val config = read(""" + libraries: + - com.github.ben-manes.caffeine:caffeine:3.2.4 + - net.kyori:adventure-nbt:5.2.0 + - pl.syntaxdevteam:syntaxcore:1.4.1-R0.1-SNAPSHOT + - pl.syntaxdevteam.licencex:SyntaxLicenceX:0.1.2-SNAPSHOT + - pl.syntaxdevteam:syntaxcore:1.4.1 + """) + assertEquals( + listOf("central", "central", "syntaxdevteam-snapshots", "syntaxdevteam-snapshots", "syntaxdevteam-releases"), + config.libraries.map { loader.selectRepository(it, config) } + ) + } + + @Test + fun explicitRepositoryWinsAndKeepsCentralForTransitives() { + val config = read(""" + libraries: + - coordinate: pl.syntaxdevteam:example:1.0-SNAPSHOT + repository: custom + """) + val id = loader.selectRepository(config.libraries.single(), config) + assertEquals("custom", id) + assertEquals(listOf("custom", "central"), loader.repositoriesFor(id, config).map { it.id }) + assertEquals(MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR, config.repositories.getValue("central").url) + assertEquals(listOf("central"), loader.repositoriesFor("central", config).map { it.id }) + } + + @Test + fun syntaxDevTeamResolverAlsoGetsCentral() { + val config = read(""" + libraries: + - pl.syntaxdevteam:syntaxcore:1.4.1-R0.1-SNAPSHOT + """) + assertEquals( + listOf("syntaxdevteam-snapshots", "central"), + loader.repositoriesFor(loader.selectRepository(config.libraries.single(), config), config).map { it.id } + ) + } + + @Test + fun rejectsUnknownRepositoryBeforeBootstrap() { + reject(""" + libraries: + - coordinate: com.example:library:1.0 + repository: missing + """) + } + + @Test + fun rejectsUnexpandedAndMalformedLibraryEntries() { + reject("libraries: ['com.example:library:" + '$' + "{version}']") + reject("libraries: [42]") + reject("libraries: [{repository: custom}]") + reject("libraries: [{coordinate: 'com.example:library:1.0', repository: 42}]") + reject("libraries: []") + } + + @Test + fun rejectsMissingCentralRepository() { + try { + loader.readLibraryConfig(ByteArrayInputStream("repositories: {}\nlibraries: ['com.example:library:1.0']".toByteArray())) + fail("Missing Central must stop bootstrap") + } catch (failure: IllegalArgumentException) { + assertTrue(failure.message.orEmpty().contains("central")) + } + } + + @Test + fun packagedManifestHasResolvedVersionsAndValidRepositories() { + val input = javaClass.classLoader.getResourceAsStream("paper-libraries.yml") + ?: error("Processed runtime manifest is missing") + val config = input.use(loader::readLibraryConfig) + assertTrue(config.libraries.isNotEmpty()) + config.libraries.forEach { library -> + assertTrue(!library.coordinate.contains("\${")) + assertTrue(loader.repositoriesFor(loader.selectRepository(library, config), config).isNotEmpty()) + } + } + + private fun reject(libraries: String) { + try { + read(libraries) + } catch (_: IllegalArgumentException) { + return + } catch (_: IllegalStateException) { + return + } + fail("Invalid runtime manifest must stop bootstrap: $libraries") + } +}