diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e87bdd3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Normalise line endings in the repository and check everything out as LF, on every platform. +# Without this, Git's autocrlf on Windows hands out CRLF working copies, and +# extension-descriptor-contract.json — which is compared byte for byte against the annotation +# processor's output, and that output is always LF — no longer matches. +* text=auto eol=lf + +# Windows batch files only work with CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf +gradlew.bat text eol=crlf + +# The wrapper script is executed by sh, so it must stay LF even on Windows checkouts. +gradlew text eol=lf + +*.jar binary +*.class binary +*.png binary +*.ico binary diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 806e4a0..6e3fbc1 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -3,8 +3,30 @@ on: [pull_request] jobs: build: - uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v2.1.0 + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v2.4.0 with: java-version: "25.0.3" java-distribution: "temurin" + # The reusable workflow's default filter anchors sources at `src/**`, which stopped + # matching when they moved into `minestom-extensions/src/` and friends. Widened to + # `**/src/**`. `**/*.java` alone would not do: non-Java files under a module's src/ — + # notably minestom-extensions-processor's META-INF/services registration, which is what + # makes the annotation processor discoverable — would never trigger a build. + paths-filters: | + code: + - '**/*.gradle' + - '**/*.gradle.kts' + - '**/gradle.properties' + - 'gradle/**' + - 'gradlew' + - 'gradlew.bat' + - 'settings.gradle' + - 'settings.gradle.kts' + - 'buildSrc/**' + - '**/src/**' + - '**/*.java' + - '**/*.kt' + - '**/*.groovy' + - '**/*.scala' + - '.github/workflows/**' secrets: inherit diff --git a/.github/workflows/close-invalid-prs.yml b/.github/workflows/close-invalid-prs.yml index 520da37..93962a7 100644 --- a/.github/workflows/close-invalid-prs.yml +++ b/.github/workflows/close-invalid-prs.yml @@ -5,6 +5,6 @@ on: jobs: close: - uses: OneLiteFeatherNET/workflows/.github/workflows/close-invalid-prs.yml@v2.1.0 + uses: OneLiteFeatherNET/workflows/.github/workflows/close-invalid-prs.yml@v2.4.0 with: protected-branch: main diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 0d0e10b..a7aaf8b 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -23,7 +23,7 @@ jobs: publish: needs: release-please if: needs.release-please.outputs.release_created == 'true' - uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.1.0 + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0 with: java-version: "25.0.3" java-distribution: "temurin" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c43fed..f8b08fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,14 +16,67 @@ By participating in this project you agree to abide by our - Git - No local Gradle install required — use the bundled wrapper (`./gradlew`) +## Project layout + +This is a Gradle multi-module build. The root project is an aggregator and publishes nothing itself; +all five published artifacts come from subprojects that share a single version: + +| Module | Artifact | Contents | +|--------|----------|----------| +| `minestom-extensions/` | `net.onelitefeather:minestom-extensions` | The extension system: `ExtensionBootstrap`, `ExtensionManager`, `Extension`, `DiscoveredExtension`, `ExtensionClassLoader`. | +| `minestom-extensions-processor/` | `net.onelitefeather:minestom-extensions-processor` | The `@ExtensionInfo` annotation and the annotation processor that generates `extension.json`. Runtime-dependency-free by design — JDK APIs only. | +| `minestom-extensions-gradle-plugin/` | `net.onelitefeather:minestom-extensions-gradle-plugin` | Gradle plugin adding build-declared libraries to `extension.json`. Built with `java-gradle-plugin`, which supplies its own `pluginMaven` publication — the root build skips creating a second one for it. | +| `minestom-extensions-maven-plugin/` | `net.onelitefeather:minestom-extensions-maven-plugin` | The same for Maven. Its `META-INF/maven/plugin.xml` is maintained by hand, see below. | +| `minestom-extensions-bom/` | `net.onelitefeather:minestom-extensions-bom` | A `java-platform` BOM pinning the modules above. | + +The packages and class names under `minestom-extensions/` are a **public contract** with downstream +consumers such as CloudNet. Do not rename or move `net.minestom.server.extensions.*` or +`net.hollowcube.minestom.extensions.ExtensionBootstrap`, even if a rename looks tidier. + +`MavenDependencyResolver` is package-private on purpose — it resolves an extension's +`externalDependencies` at startup and is an implementation detail, not part of that contract. It +wires Maven Artifact Resolver through the deprecated `MavenRepositorySystemUtils.newServiceLocator()` +rather than the newer `RepositorySystemSupplier`, and that is deliberate: the supplier builds a +resolver whose descriptor reader does not interpret POMs, so transitive dependencies silently +resolve to nothing. Read the class javadoc before "modernising" it. + +Shared build logic — the Java 25 toolchain, sources/javadoc jars, and the whole `maven-publish` +setup — lives once in the root `build.gradle.kts`. A module's own build file should only carry what +is genuinely specific to it (its `description` and its dependencies). + ## Building and testing ```bash -./gradlew build # compile and run the full test suite -./gradlew test # run the tests only +./gradlew build # build and test every module +./gradlew test # run the tests only +./gradlew :minestom-extensions-processor:test # a single module ``` -Tests run on JUnit 5 with `-Dminestom.inside-test=true` already configured by the build. +Tests run on JUnit 5. The core module's test task additionally sets `-Dminestom.inside-test=true`, +which Minestom requires; the processor's tests are plain JUnit and need no such flag. Both are +already configured by the build — you do not need to pass anything by hand. + +One test spans both code modules and is worth knowing about before you touch either end. +`minestom-extensions/src/test/resources/extension-descriptor-contract.json` is the exact +`extension.json` the processor emits for a fully populated `@ExtensionInfo`. The processor module +copies that file in and asserts it still reproduces it; the core module deserializes it into the +real `DiscoveredExtension` and asserts every field arrives. This is deliberate belt-and-braces: +the processor does not depend on the core module, and Gson silently ignores JSON members it does +not recognise — so without those two tests, renaming a field in `DiscoveredExtension` would break +every extension at runtime while leaving the whole build green. If you change the descriptor format, +regenerate that file and expect both suites to move together. + +The Maven plugin's descriptor needs the same kind of care. A Maven build would generate +`META-INF/maven/plugin.xml` from the `@Mojo` annotations, but the Gradle equivalent for that calls an +API Gradle 9 removed, so the file is maintained by hand under `src/main/resources`. `PluginDescriptorTest` +compares it against the mojo's fields, because a drifting descriptor fails in the *user's* build — +Maven either reports a parameter as unknown or silently never injects it. + +Both build plugins deliberately write their result to a file separate from the one the annotation +processor produced, instead of editing it in place. Reading back their own output would mean that a +dependency removed from the build lingers in the descriptor forever, since the compile task stays up +to date and never regenerates it. Both have a regression test for exactly that. + Please make sure `./gradlew build` passes before opening a pull request. ## Branching and pull requests @@ -76,8 +129,13 @@ refactor!: remove deprecated DemoServer entrypoint ## Releases Releases are handled automatically: release-please opens a release PR that bumps the version in -`gradle.properties` and updates `CHANGELOG.md`. Merging that PR tags the release and publishes the -artifact to the OneLiteFeather Maven repository. Contributors do not need to bump versions manually. +`gradle.properties` and updates `CHANGELOG.md`. Merging that PR tags the release and publishes all +five modules to the OneLiteFeather Maven repository. Contributors do not need to bump versions +manually. + +All modules share one version, inherited from the single `version` entry in `gradle.properties` — +they are always released together and never versioned independently. The `# x-release-please-version` +marker comment on that line is what release-please rewrites, so leave it in place. ## Reporting bugs and requesting features diff --git a/README.md b/README.md index a509fda..c162cd3 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,34 @@ This library is not quite a drop-in replacement for the original Minestom extens For many extensions it should work out of the box. If an extension references `MinecraftServer.getExtensionManager()` this will have to be updated, see [Usage](#usage) for more information. +## Modules + +| Artifact | Description | +| --- | --- | +| `net.onelitefeather:minestom-extensions` | The extension system itself: `ExtensionBootstrap`, `ExtensionManager`, `Extension`, `DiscoveredExtension` and `ExtensionClassLoader`. This is what a server depends on. | +| `net.onelitefeather:minestom-extensions-processor` | The `@ExtensionInfo` annotation and the annotation processor that generates `extension.json` at compile time. Used when *writing* an extension, see [Generating extension.json](#generating-extensionjson). | +| `net.onelitefeather:minestom-extensions-gradle-plugin` | Optional. Lets the Gradle build declare the libraries an extension loads at runtime, instead of repeating their coordinates in the annotation. See [Declaring dependencies in the build](#declaring-dependencies-in-the-build). | +| `net.onelitefeather:minestom-extensions-maven-plugin` | Optional. The same for Maven builds. | +| `net.onelitefeather:minestom-extensions-bom` | Bill of Materials (`pom` packaging) that pins the versions of the modules above so they can be declared without a version. | + ## Requirements - Java 25 or newer ## Install -Artifacts are published to the OneLiteFeather Maven repository. Add the repository and the dependency to your build. +Artifacts are published to the OneLiteFeather Maven repository. Add the repository and the +dependencies to your build. + +The recommended way is to import the BOM and declare the modules without a version, so the two +artifacts can never drift apart. The annotation processor is declared twice: as +`annotationProcessor` so it runs during compilation, and as `compileOnly` so the annotations are +visible to the compiler. It is deliberately not a regular `implementation` dependency, because +`@ExtensionInfo` is `@Retention(SOURCE)` and therefore has no business being in the finished jar. + +Note that the BOM has to be imported into `annotationProcessor` as well. That configuration extends +nothing, so a platform declared on `implementation` never reaches it and the versionless +`annotationProcessor(...)` line would have no version to resolve against. ### Gradle (Kotlin DSL) @@ -32,7 +53,13 @@ repositories { } dependencies { - implementation("net.onelitefeather:minestom-extensions:") + implementation(platform("net.onelitefeather:minestom-extensions-bom:")) + annotationProcessor(platform("net.onelitefeather:minestom-extensions-bom:")) + + implementation("net.onelitefeather:minestom-extensions") + + compileOnly("net.onelitefeather:minestom-extensions-processor") + annotationProcessor("net.onelitefeather:minestom-extensions-processor") } ``` @@ -45,7 +72,13 @@ repositories { } dependencies { - implementation 'net.onelitefeather:minestom-extensions:' + implementation platform('net.onelitefeather:minestom-extensions-bom:') + annotationProcessor platform('net.onelitefeather:minestom-extensions-bom:') + + implementation 'net.onelitefeather:minestom-extensions' + + compileOnly 'net.onelitefeather:minestom-extensions-processor' + annotationProcessor 'net.onelitefeather:minestom-extensions-processor' } ``` @@ -59,13 +92,70 @@ dependencies { + + + + net.onelitefeather + minestom-extensions-bom + RELEASE_VERSION + pom + import + + + + net.onelitefeather minestom-extensions - RELEASE_VERSION + + + net.onelitefeather + minestom-extensions-processor + provided + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + + + net.onelitefeather + minestom-extensions-processor + + + + + + +``` + +The `` block is not optional. Since JDK 23 `javac` no longer discovers +annotation processors from the compile classpath, so a processor that is only a `provided` +dependency is silently never run: the build stays green and the jar ships without an +`extension.json`, and the extension then dies at server startup with +`Missing extension.json in extension `. Putting the processor on the processor path is what +actually makes it run. + +The `provided` dependency is still needed - it puts `@ExtensionInfo` on the compile classpath while +keeping it out of the packaged artifact. The `` entry needs no ``; the compiler +plugin takes it from that dependency, and therefore from the BOM (requires +`maven-compiler-plugin` 3.12.0 or newer). + +### Without the BOM + +If you would rather not use the BOM, declare the versions yourself. Only the core module is +required; the processor is optional and only needed if you want `extension.json` generated for you. + +```kotlin +dependencies { + implementation("net.onelitefeather:minestom-extensions:") +} ``` Snapshot builds are available from `https://repo.onelitefeather.dev/snapshots`. @@ -88,13 +178,229 @@ server.start("0.0.0.0", 25565); If you need to access the `ExtensionManager` from your code, it can be done using `ExtensionBootstrap.getExtensionManager()`. +## Generating extension.json + +Every extension needs an `extension.json` in the root of its jar. Instead of writing and maintaining +that file by hand, annotate your entrypoint with `@ExtensionInfo` and let +`minestom-extensions-processor` generate it during compilation. There is deliberately no +`entrypoint()` element: the entrypoint is always the annotated class itself, so it can never drift +away from a rename or a package move. + +```java +package com.example.chat; + +import net.minestom.server.extensions.Extension; +import net.onelitefeather.minestom.extensions.processor.ExtensionInfo; +import net.onelitefeather.minestom.extensions.processor.ExternalDependency; +import net.onelitefeather.minestom.extensions.processor.Repository; + +@ExtensionInfo( + name = "ChatFormatter", + authors = {"Alice", "Bob"}, + dependencies = {"PermissionBridge"}, + repositories = { + @Repository(name = "onelitefeather", url = "https://repo.onelitefeather.dev/releases") + }, + externalDependencies = { + @ExternalDependency("com.google.guava:guava:33.4.0-jre"), + @ExternalDependency("org.apache.commons:commons-lang3:3.17.0") + } +) +public final class ChatFormatter extends Extension { + + @Override + public void initialize() { + getLogger().info("ChatFormatter enabled"); + } + + @Override + public void terminate() { + } +} +``` + +> `Extension#getLogger()` returns an Adventure `ComponentLogger`, whose `info(...)` is inherited +> from `org.slf4j.Logger`. That interface reaches you only at runtime, so calling it needs +> `compileOnly("org.slf4j:slf4j-api")` in your own build — otherwise javac reports +> *"cannot access Logger"*. + +This produces `extension.json` in the root of the compiled output, and therefore in the root of the +resulting jar, which is exactly where `ExtensionManager` looks for it (`version` comes from the +compiler argument shown in the next section — the annotation above does not declare one): + +```json +{ + "name": "ChatFormatter", + "entrypoint": "com.example.chat.ChatFormatter", + "version": "1.4.0", + "authors": [ + "Alice", + "Bob" + ], + "dependencies": [ + "PermissionBridge" + ], + "externalDependencies": { + "repositories": [ + { + "name": "onelitefeather", + "url": "https://repo.onelitefeather.dev/releases" + } + ], + "artifacts": [ + "com.google.guava:guava:33.4.0-jre", + "org.apache.commons:commons-lang3:3.17.0" + ] + } +} +``` + +`authors`, `dependencies` and `externalDependencies` are omitted entirely when they are empty. The +runtime defaults every missing field, so a minimal descriptor only carries `name` and `entrypoint`. + +At startup, `ExtensionManager` resolves everything under `externalDependencies` from the declared +repositories — transitive dependencies included — and adds each jar to the extension's classloader. +Downloads are cached in `extensions/.libs`. Resolution runs on [Maven Artifact +Resolver](https://maven.apache.org/resolver/), the resolver Maven itself uses, with checksum +verification enabled. + +> Declare a repository you control or a Maven Central mirror. Using `repo1.maven.org` directly as a +> download endpoint for shipped software is against the [Maven Central Terms of +> Service](https://central.sonatype.org/faq/is-there-a-limit-on-artifact-size/), and your users may +> run into rate limits. + +### The version comes from the build + +Note that the example above does not set `version()` on the annotation. The version usually already +lives in the build, and duplicating it in the source is how the two get out of sync. + +If you use one of the [build plugins](#declaring-dependencies-in-the-build), this is already handled: +they write the project version into the descriptor, and there is nothing to configure. Set +`useProjectVersion = false` (Gradle) or `false` (Maven) to +keep the version in the annotation instead. + +Without a build plugin, pass it as a compiler argument: + +```kotlin +tasks.withType { + options.compilerArgs.add("-Aminestom.extension.version=${project.version}") +} +``` + +Either way the build wins over `version()`. If neither is set, the processor warns and omits the +field, and the runtime reports the version as `Unspecified`. The name can be injected the same way +with `-Aminestom.extension.name=`. + +> The Gradle plugin ignores an unset project version. Gradle defaults it to the string +> `unspecified`, and writing that into a descriptor would be worse than leaving the annotation alone. + +### What the processor validates + +The generated descriptor is only useful if the runtime accepts it, so the processor checks at +compile time what `ExtensionManager` would otherwise only discover at server startup. It fails the +build when: + +- more than one class in the compilation is annotated with `@ExtensionInfo` (an `extension.json` + describes exactly one entrypoint), +- `name()` does not match `[A-Za-z][_A-Za-z0-9]+`, which the runtime would reject as `INVALID_NAME`, +- the annotated type is not a class, is abstract, is an inner (non-static nested) class, or has no + no-arg constructor (`ExtensionManager` instantiates the entrypoint reflectively), +- the annotated type does not extend `Extension` (only checked when `Extension` is on the compile + classpath, otherwise skipped silently), +- a `@Repository` has a blank name or a URL that does not start with `http://` or `https://`, +- an `@ExternalDependency` has a blank coordinate. + +It warns, without failing the build, about a missing version, duplicate entries in +`dependencies()` (the duplicate is dropped), a coordinate that does not look like +`group:artifact:version`, and a non-public entrypoint class or constructor. The last one is only a +warning on purpose: `ExtensionManager` calls `setAccessible(true)`, so a package-private entrypoint +does load, it is simply not recommended. + +## Declaring dependencies in the build + +Writing `@ExternalDependency("com.google.guava:guava:33.4.0-jre")` in the annotation means the +version lives in the source as well as in the build, and the two drift apart. The Gradle and Maven +plugins let the build state it once. + +Both plugins run after the annotation processor and add to what it generated, so anything declared +in `@ExtensionInfo` is kept. On a clash the annotation wins — except for the version, where the build +wins, since that is the value it already maintains. See [The version comes from the +build](#the-version-comes-from-the-build). + +### Gradle + +```kotlin +plugins { + java + id("net.onelitefeather.minestom-extensions") version "" +} + +repositories { + mavenCentral() +} + +dependencies { + extensionLibrary("com.google.guava:guava:33.4.0-jre") +} +``` + +`extensionLibrary` is a declaration-only configuration: it is on no compile or runtime classpath and +nothing from it is bundled. It exists purely to describe what the extension resolves for itself at +startup. Coordinates need an explicit group and version — the server resolves them with no access to +your version catalog or platforms, so a versionless entry fails the build rather than producing a +descriptor that cannot be resolved. + +The repositories declared in the project are written into the descriptor. To add one that the build +itself does not use, or to take full control: + +```kotlin +minestomExtension { + repository("onelitefeather", "https://repo.onelitefeather.dev/releases") + inheritProjectRepositories = false +} +``` + +### Maven + +```xml + + net.onelitefeather + minestom-extensions-maven-plugin + RELEASE_VERSION + + + + org.apache.commons:commons-lang3 + + com.google.guava:guava:33.4.0-jre + + + + + describe + + + +``` + +The goal binds to `process-classes` and needs no further configuration. Which dependencies to record +is stated explicitly rather than derived from a scope: everything a Minestom extension compiles +against is `provided` — Minestom, `minestom-extensions`, the processor itself — and none of those +belong in the descriptor. + ## Building from source -The project uses the Gradle wrapper and a Java 25 toolchain. +The project uses the Gradle wrapper and a Java 25 toolchain, and is split into the modules listed +under [Modules](#modules). ```bash -./gradlew build # compile and run the tests -./gradlew test # run the tests only +./gradlew build # build and test every module +./gradlew test # run the tests of every module +./gradlew projects # list the modules + +./gradlew :minestom-extensions:build # the extension system only +./gradlew :minestom-extensions-processor:test # the annotation processor tests only +./gradlew publishToMavenLocal # all three artifacts into ~/.m2 ``` Publishing to the OneLiteFeather repository requires the `ONELITEFEATHER_MAVEN_USERNAME` and diff --git a/build.gradle.kts b/build.gradle.kts index bab9a32..683bcfe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,99 +1,123 @@ plugins { - `java-library` - `maven-publish` + base } -version = (version as String).substringBefore('#').trim() -description = "Extensions for minestom, added externally as a library" +/** + * Shared metadata for every publishable module. Only `name`, `description` and `artifactId` + * differ per module - everything below is declared exactly once and reused. + */ +val projectUrl = "https://github.com/OneLiteFeatherNET/minestom-extensions" +allprojects { + group = rootProject.group -dependencies { - implementation(platform(libs.myclium.bom)) - compileOnly(libs.minestom) - implementation(libs.dependency.getter) - implementation(libs.slf4j2) - - testImplementation(platform(libs.myclium.bom)) - testImplementation(libs.minestom) - testImplementation(libs.junit.api) - testImplementation(libs.junit.platform.launcher) - testRuntimeOnly(libs.junit.engine) - testImplementation(libs.logback.classic) + // gradle.properties carries "version = 2.0.0 # x-release-please-version". + // Strip the release-please marker comment for every project. + version = (version as String).substringBefore('#').trim() } -java { - withSourcesJar() - withJavadocJar() +subprojects { + apply(plugin = "maven-publish") - toolchain { - languageVersion.set(JavaLanguageVersion.of(25)) - } -} + // --- Java modules (java-library). The java-platform BOM never matches this block, + // --- so it correctly never gets a sources-/javadoc-jar or a toolchain. + plugins.withId("java-library") { + extensions.configure { + withSourcesJar() + withJavadocJar() -tasks { - test { - useJUnitPlatform() - jvmArgs("-Dminestom.inside-test=true") + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } + } } -} -publishing { - publications.create("maven") { - groupId = "net.onelitefeather" - artifactId = "minestom-extensions" - version = project.version.toString() + extensions.configure { + // --- One publication per module, created from whichever component exists. + // --- Deferred to afterEvaluate because java-gradle-plugin supplies its own `pluginMaven` + // --- for the very same artifact; creating ours as well would publish the identical GAV + // --- twice, which a release repository rejects on the second upload. + plugins.withId("java-library") { + afterEvaluate { + // Checking for the plugin, not for the `pluginMaven` publication: that publication + // is itself created in an afterEvaluate, and ours runs first, so it would not be + // visible yet. + if (!pluginManager.hasPlugin("java-gradle-plugin")) { + publications.create("maven") { + from(components["java"]) + } + } + } + } + plugins.withId("java-platform") { + publications.create("maven") { + from(components["javaPlatform"]) + } + } + + // --- Central POM boilerplate, applied to whatever publication a module declares. + publications.withType().configureEach { + groupId = project.group.toString() + version = project.version.toString() - from(project.components["java"]) + // Gradle plugin markers are a fixed coordinate derived from the plugin id - that is how + // `plugins { id(...) }` resolves them - so they must keep the name Gradle gave them. + if (!name.endsWith("PluginMarkerMaven")) { + artifactId = project.name + } - pom { - name.set("minestom-extensions") - description.set("Extensions for minestom, added externally as a library") - url.set("https://github.com/OneLiteFeatherNET/minestom-extensions") + pom { + name.set(project.name) + description.set(provider { project.description }) + url.set(projectUrl) - licenses { - license { - name.set("Apache 2.0") - url.set("https://github.com/OneLiteFeatherNET/minestom-extensions/blob/main/LICENSE") + licenses { + license { + name.set("Apache 2.0") + url.set("$projectUrl/blob/main/LICENSE") + } } - } - developers { - developer { - id.set("Minestom Contributors") + developers { + developer { + id.set("Minestom Contributors") + } } - } - issueManagement { - system.set("GitHub") - url.set("https://github.com/OneLiteFeatherNET/minestom-extensions/issues") - } + issueManagement { + system.set("GitHub") + url.set("$projectUrl/issues") + } - scm { - connection.set("scm:git:git://github.com/OneLiteFeatherNET/minestom-extensions.git") - developerConnection.set("scm:git:git@github.com:OneLiteFeatherNET/minestom-extensions.git") - url.set("https://github.com/OneLiteFeatherNET/minestom-extensions") - tag.set(version) - } + scm { + connection.set("scm:git:git://github.com/OneLiteFeatherNET/minestom-extensions.git") + developerConnection.set("scm:git:git@github.com:OneLiteFeatherNET/minestom-extensions.git") + url.set(projectUrl) + tag.set(project.version.toString()) + } - ciManagement { - system.set("Github Actions") - url.set("https://github.com/OneLiteFeatherNET/minestom-extensions/actions") + ciManagement { + system.set("Github Actions") + url.set("$projectUrl/actions") + } } } - } - repositories { - maven { - authentication { - credentials(PasswordCredentials::class) { - username = System.getenv("ONELITEFEATHER_MAVEN_USERNAME") - password = System.getenv("ONELITEFEATHER_MAVEN_PASSWORD") + + // --- Central publishing target. + repositories { + maven { + authentication { + credentials(PasswordCredentials::class) { + username = System.getenv("ONELITEFEATHER_MAVEN_USERNAME") + password = System.getenv("ONELITEFEATHER_MAVEN_PASSWORD") + } } - } - name = "OneLiteFeatherRepository" - val releasesRepoUrl = uri("https://repo.onelitefeather.dev/releases") - val snapshotsRepoUrl = uri("https://repo.onelitefeather.dev/snapshots") - url = if (version.toString().contains("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl + name = "OneLiteFeatherRepository" + val releasesRepoUrl = uri("https://repo.onelitefeather.dev/releases") + val snapshotsRepoUrl = uri("https://repo.onelitefeather.dev/snapshots") + url = if (project.version.toString().contains("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl + } } } } diff --git a/minestom-extensions-bom/build.gradle.kts b/minestom-extensions-bom/build.gradle.kts new file mode 100644 index 0000000..8ee34cd --- /dev/null +++ b/minestom-extensions-bom/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + `java-platform` +} + +description = "Bill of Materials for the minestom-extensions modules" + +// No allowDependencies() - this platform only declares constraints. +dependencies { + constraints { + api(project(":minestom-extensions")) + api(project(":minestom-extensions-processor")) + // The build plugins are pinned here too. It is what lets a Maven user put the plugin + // version under dependencyManagement instead of repeating it, and it keeps all four + // artifacts moving as one version. + api(project(":minestom-extensions-gradle-plugin")) + api(project(":minestom-extensions-maven-plugin")) + } +} diff --git a/minestom-extensions-gradle-plugin/build.gradle.kts b/minestom-extensions-gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..26445cd --- /dev/null +++ b/minestom-extensions-gradle-plugin/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + `java-gradle-plugin` +} + +description = "Gradle plugin feeding build-declared dependencies into extension.json" + +dependencies { + // ExtensionDescriptor reads and renders the file; sharing it with the processor is what keeps + // the formatting and the ASCII escaping identical no matter which side writes. It carries no + // dependencies of its own, so the plugin adds nothing to a consumer's buildscript classpath. + implementation(project(":minestom-extensions-processor")) + + testImplementation(platform(libs.myclium.bom)) + testImplementation(libs.junit.api) + testImplementation(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.engine) + testImplementation(gradleTestKit()) +} + +gradlePlugin { + plugins { + create("minestomExtension") { + id = "net.onelitefeather.minestom-extensions" + implementationClass = "net.onelitefeather.minestom.extensions.gradle.MinestomExtensionPlugin" + displayName = "Minestom extension descriptor" + description = "Adds the dependencies declared in the build to the generated extension.json" + } + } +} + +// The functional test runs a real build with the real annotation processor attached, so it needs +// the processor jar. Handing over the path beats publishing to mavenLocal from a test. +val processorJar = tasks.register("processorJarForTest") { + from(project(":minestom-extensions-processor").tasks.named("jar")) + into(layout.buildDirectory.dir("test-processor")) +} + +tasks { + test { + useJUnitPlatform() + dependsOn(processorJar) + systemProperty("processor.jar.dir", + layout.buildDirectory.dir("test-processor").get().asFile.absolutePath) + // TestKit builds a throwaway project per test; without this it inherits the outer daemon's + // memory settings and the suite gets noticeably slower. + maxHeapSize = "1g" + } +} diff --git a/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/ExtensionDescriptorTask.java b/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/ExtensionDescriptorTask.java new file mode 100644 index 0000000..2a45202 --- /dev/null +++ b/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/ExtensionDescriptorTask.java @@ -0,0 +1,151 @@ +package net.onelitefeather.minestom.extensions.gradle; + +import net.onelitefeather.minestom.extensions.processor.ExtensionDescriptor; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Rewrites the {@code extension.json} produced by the annotation processor, adding the external + * dependencies declared in the build. + * + *

The result is written to a separate file rather than over the processor's copy, and the jar is + * configured to take this one instead. Editing in place would make the task's own output its next + * input, with a consequence worse than a missed up-to-date check: removing an {@code + * extensionLibrary} would leave the old coordinate in the descriptor forever, because {@code + * compileJava} stays up to date and never regenerates the file the task reads. + */ +@DisableCachingByDefault(because = "Writing a few hundred bytes of JSON is cheaper than a cache lookup") +public abstract class ExtensionDescriptorTask extends DefaultTask { + + /** + * The descriptor generated by {@code minestom-extensions-processor}. + * + * @return the property + */ + @InputFile + @PathSensitive(PathSensitivity.RELATIVE) + public abstract RegularFileProperty getDescriptor(); + + /** + * Maven coordinates to record, as {@code group:artifact:version}. + * + * @return the property + */ + @Input + public abstract ListProperty getArtifacts(); + + /** + * Repositories to record, keyed by name. + * + * @return the property + */ + @Input + public abstract MapProperty getRepositories(); + + /** + * Version to record, overriding whatever {@code @ExtensionInfo} declared. Left empty, the + * annotation's value stands. + * + * @return the property + */ + @Input + @Optional + public abstract Property getVersion(); + + /** + * Where the enriched descriptor is written. The jar picks this up in place of the processor's + * copy. + * + * @return the property + */ + @OutputFile + public abstract RegularFileProperty getOutput(); + + /** Merges the build-declared dependencies into the descriptor. */ + @TaskAction + public void mergeDescriptor() { + final List artifacts = getArtifacts().get(); + final Map repositories = getRepositories().get(); + + final File file = getDescriptor().get().getAsFile(); + final ExtensionDescriptor descriptor; + try { + descriptor = ExtensionDescriptor.fromJson( + Files.readString(file.toPath(), StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException("Could not read " + file, e); + } catch (IllegalArgumentException e) { + throw new GradleException(file + " is not a usable extension descriptor: " + + e.getMessage(), e); + } + + // The build is the better source for the version: keeping it in the annotation as well means + // maintaining it twice, and the copy in the source is the one that goes stale. + if (getVersion().isPresent()) { + descriptor.version(getVersion().get()); + } + + if (artifacts.isEmpty()) { + // Nothing to add, but the jar still reads the output copy, so it has to exist. + write(descriptor.toJson()); + return; + } + + // Coordinates and repositories already stated in @ExtensionInfo are kept; ExtensionDescriptor + // ignores duplicates and keeps the first repository declared under a given name, so the + // annotation wins on a clash. + descriptor.artifacts(artifacts); + final List entries = new ArrayList<>(); + repositories.forEach((name, url) -> + entries.add(new ExtensionDescriptor.RepositoryEntry(name, url))); + descriptor.repositories(entries); + + if (descriptor.repositories().isEmpty()) { + throw new GradleException(""" + The extension declares external dependencies but no repository to resolve them \ + from, so the server would fail to load it. Declare a repository in the project, \ + or add one explicitly: + + minestomExtension { + repository("central", "https://repo1.maven.org/maven2/") + }"""); + } + + write(descriptor.toJson()); + + getLogger().lifecycle("Recorded {} external {} in extension.json", + descriptor.artifacts().size(), + descriptor.artifacts().size() == 1 ? "dependency" : "dependencies"); + } + + private void write(String json) { + final File target = getOutput().get().getAsFile(); + try { + Files.createDirectories(target.toPath().getParent()); + Files.writeString(target.toPath(), json, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Could not write " + target, e); + } + } +} diff --git a/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionPlugin.java b/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionPlugin.java new file mode 100644 index 0000000..c3ec5ae --- /dev/null +++ b/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionPlugin.java @@ -0,0 +1,169 @@ +package net.onelitefeather.minestom.extensions.gradle; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.repositories.MavenArtifactRepository; +import org.gradle.api.plugins.JavaPlugin; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.bundling.Jar; +import org.gradle.api.tasks.compile.JavaCompile; +import org.gradle.language.jvm.tasks.ProcessResources; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; + +/** + * Lets a build declare the libraries an extension loads at runtime, and writes them into the + * generated {@code extension.json}. + * + *

Without this plugin the coordinates have to be repeated in {@code @ExtensionInfo}, next to the + * same versions the build already knows - two places to update, one of which silently goes stale. + * + *

{@code
+ * plugins {
+ *     java
+ *     id("net.onelitefeather.minestom-extensions")
+ * }
+ *
+ * dependencies {
+ *     extensionLibrary("com.google.guava:guava:33.4.0-jre")
+ * }
+ * }
+ * + *

The {@code extensionLibrary} configuration is not on any compile or runtime classpath. It only + * records what the extension resolves for itself at startup, which is exactly the point: those jars + * must not be bundled or leak into the consumer's classpath. + */ +public class MinestomExtensionPlugin implements Plugin { + + /** Name of the configuration holding the runtime-resolved libraries. */ + public static final String CONFIGURATION_NAME = "extensionLibrary"; + + /** Name of the task that rewrites the descriptor. */ + public static final String TASK_NAME = "extensionDescriptor"; + + /** Name of the generated descriptor. */ + static final String DESCRIPTOR_NAME = "extension.json"; + + /** Name of the {@code minestomExtension} block. */ + public static final String SPEC_NAME = "minestomExtension"; + + @Override + public void apply(Project project) { + project.getPluginManager().apply(JavaPlugin.class); + + final MinestomExtensionSpec spec = + project.getExtensions().create(SPEC_NAME, MinestomExtensionSpec.class); + spec.getInheritProjectRepositories().convention(true); + spec.getUseProjectVersion().convention(true); + + final Configuration libraries = project.getConfigurations().create(CONFIGURATION_NAME, it -> { + it.setDescription("Libraries the extension resolves at runtime, recorded in extension.json"); + // Declarable only: these never belong on a classpath, they are metadata for the server. + it.setCanBeResolved(false); + it.setCanBeConsumed(false); + it.setVisible(false); + }); + + final var descriptorTask = project.getTasks().register(TASK_NAME, ExtensionDescriptorTask.class, task -> { + task.setGroup("build"); + task.setDescription("Adds the build-declared dependencies to extension.json"); + + task.getDescriptor().fileProvider(project.provider(() -> + new java.io.File(mainOutputDir(project), DESCRIPTOR_NAME))); + task.getOutput().set(project.getLayout().getBuildDirectory() + .file("generated/minestom-extension/" + DESCRIPTOR_NAME)); + + task.getArtifacts().set(project.provider(() -> libraries.getDependencies().stream() + .map(dependency -> { + if (dependency.getGroup() == null || dependency.getVersion() == null) { + throw new org.gradle.api.GradleException( + "The " + CONFIGURATION_NAME + " dependency '" + dependency + + "' needs an explicit group and version: the server resolves " + + "it from Maven coordinates, with no access to this build's " + + "version catalog or platforms."); + } + return dependency.getGroup() + ":" + dependency.getName() + ":" + dependency.getVersion(); + }) + .toList())); + + task.getRepositories().set(project.provider(() -> repositoriesFor(project, spec))); + task.getVersion().set(project.provider(() -> versionFor(project, spec))); + }); + + // The processor writes the descriptor during compileJava, so the task has to run after it. + descriptorTask.configure(task -> task.dependsOn( + project.getTasks().withType(JavaCompile.class), + project.getTasks().withType(ProcessResources.class))); + + project.getTasks().withType(Jar.class).configureEach(jar -> { + jar.from(descriptorTask); + // Both copies would otherwise land in the jar. Dropping it via eachFile rather than + // exclude() is deliberate: an exclude on the jar spec applies to every source including + // the one just added above, which would leave the jar with no descriptor at all. + jar.eachFile(details -> { + if (DESCRIPTOR_NAME.equals(details.getSourcePath()) + && !details.getFile().toPath().startsWith( + generatedDir(project).toPath())) { + details.exclude(); + } + }); + }); + } + + /** + * The version to record, or {@code null} to leave whatever the annotation declared. + * + *

Gradle defaults an unset project version to the string {@code "unspecified"}. Writing that + * into a descriptor would replace a perfectly good annotation value with a placeholder, so it is + * treated as "no version set". + */ + private static String versionFor(Project project, MinestomExtensionSpec spec) { + if (!Boolean.TRUE.equals(spec.getUseProjectVersion().get())) { + return null; + } + final String version = String.valueOf(project.getVersion()); + return version.isBlank() || "unspecified".equals(version) ? null : version; + } + + private static java.io.File generatedDir(Project project) { + return project.getLayout().getBuildDirectory() + .dir("generated/minestom-extension").get().getAsFile(); + } + + /** Collects the repositories to record, project ones first, explicit ones last. */ + private static Map repositoriesFor(Project project, MinestomExtensionSpec spec) { + final Map repositories = new LinkedHashMap<>(); + + if (Boolean.TRUE.equals(spec.getInheritProjectRepositories().get())) { + for (var repository : project.getRepositories()) { + if (!(repository instanceof MavenArtifactRepository maven)) { + continue; + } + final String url = maven.getUrl().toString(); + // A local path is meaningless on the server, and mavenLocal() would point at the + // build machine's home directory. + if (!url.startsWith("http://") && !url.startsWith("https://")) { + continue; + } + repositories.put(maven.getName(), url); + } + } + + repositories.putAll(spec.getRepositories().get()); + // Sorted so the descriptor does not churn when Gradle varies repository iteration order. + return new TreeMap<>(repositories); + } + + private static java.io.File mainOutputDir(Project project) { + final SourceSetContainer sourceSets = + project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets(); + return sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME) + .getOutput().getClassesDirs().getFiles().iterator().next(); + } +} diff --git a/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionSpec.java b/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionSpec.java new file mode 100644 index 0000000..28966e2 --- /dev/null +++ b/minestom-extensions-gradle-plugin/src/main/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionSpec.java @@ -0,0 +1,63 @@ +package net.onelitefeather.minestom.extensions.gradle; + +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; + +/** + * Configuration for the {@code net.onelitefeather.minestom-extensions} plugin, available as the + * {@code minestomExtension} block. + * + *

{@code
+ * minestomExtension {
+ *     // add a repository the runtime should search, on top of the project's own
+ *     repository("onelitefeather", "https://repo.onelitefeather.dev/releases")
+ *
+ *     // or take full control and ignore the project repositories entirely
+ *     inheritProjectRepositories = false
+ * }
+ * }
+ */ +public abstract class MinestomExtensionSpec { + + /** + * Whether the repositories declared in the project are written into the descriptor. + * + *

Defaults to {@code true}, which is right for the common case where an extension resolves + * its libraries from the same places the build does. Turn it off when the server should use + * different repositories than the build machine - an internal mirror, for instance. + * + * @return the property, defaulting to {@code true} + */ + public abstract Property getInheritProjectRepositories(); + + /** + * Whether the project version is written into the descriptor, overriding + * {@code @ExtensionInfo(version = ...)}. + * + *

Defaults to {@code true}: the build already knows the version, and a copy kept in the + * annotation is the one that goes stale. Turn it off to keep the version in the source. + * + *

Has no effect while the project version is Gradle's {@code unspecified} placeholder — + * writing that into a descriptor would be worse than leaving the annotation alone. + * + * @return the property, defaulting to {@code true} + */ + public abstract Property getUseProjectVersion(); + + /** + * Additional repositories, keyed by name. + * + * @return the property + */ + public abstract MapProperty getRepositories(); + + /** + * Adds a repository the runtime should search for external dependencies. + * + * @param name identifier of the repository + * @param url base url, must be http(s) + */ + public void repository(String name, String url) { + getRepositories().put(name, url); + } +} diff --git a/minestom-extensions-gradle-plugin/src/test/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionPluginTest.java b/minestom-extensions-gradle-plugin/src/test/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionPluginTest.java new file mode 100644 index 0000000..18cd8a5 --- /dev/null +++ b/minestom-extensions-gradle-plugin/src/test/java/net/onelitefeather/minestom/extensions/gradle/MinestomExtensionPluginTest.java @@ -0,0 +1,304 @@ +package net.onelitefeather.minestom.extensions.gradle; + +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipFile; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runs real builds with both the annotation processor and this plugin attached, and asserts on the + * {@code extension.json} that ends up in the jar. + * + *

The point of these is the seam between the two: the processor writes the descriptor during + * {@code compileJava}, the plugin rewrites it afterwards, and the jar has to pick up the rewritten + * one. Nothing but an end-to-end build proves that ordering holds. + */ +class MinestomExtensionPluginTest { + + @Test + @DisplayName("a build-declared library ends up in extension.json inside the jar") + void recordsExtensionLibrary(@TempDir Path dir) throws Exception { + writeProject(dir, """ + dependencies { + extensionLibrary("com.google.guava:guava:33.4.0-jre") + } + """); + + final BuildResult result = build(dir, "jar"); + + assertEquals(TaskOutcome.SUCCESS, outcomeOf(result)); + + final String json = descriptorFromJar(dir); + assertAll( + () -> assertTrue(json.contains("\"com.google.guava:guava:33.4.0-jre\""), + () -> "coordinate missing from descriptor:\n" + json), + // The repository comes from the project's own repositories block. + () -> assertTrue(json.contains("https://repo1.maven.org/maven2/"), + () -> "repository missing from descriptor:\n" + json), + // Everything the annotation stated has to survive the rewrite. + () -> assertTrue(json.contains("\"name\": \"Sample\""), json), + () -> assertTrue(json.contains("\"entrypoint\": \"com.example.SampleExtension\""), json)); + } + + @Test + @DisplayName("what the annotation already declared is kept alongside the build's libraries") + void annotationAndBuildAreMerged(@TempDir Path dir) throws Exception { + writeProject(dir, """ + dependencies { + extensionLibrary("com.google.guava:guava:33.4.0-jre") + } + """, """ + externalDependencies = { + @ExternalDependency("org.apache.commons:commons-lang3:3.17.0") + }, + """); + + build(dir, "jar"); + + final String json = descriptorFromJar(dir); + assertAll( + () -> assertTrue(json.contains("org.apache.commons:commons-lang3:3.17.0"), + () -> "the annotation's coordinate was dropped:\n" + json), + () -> assertTrue(json.contains("com.google.guava:guava:33.4.0-jre"), + () -> "the build's coordinate is missing:\n" + json)); + } + + @Test + @DisplayName("the descriptor is untouched when the build declares no libraries") + void noLibrariesLeavesDescriptorAlone(@TempDir Path dir) throws Exception { + writeProject(dir, ""); + + build(dir, "jar"); + + final String json = descriptorFromJar(dir); + assertAll( + () -> assertTrue(json.contains("\"name\": \"Sample\""), json), + () -> assertTrue(!json.contains("externalDependencies"), + () -> "nothing was declared, so the field should be absent:\n" + json)); + } + + @Test + @DisplayName("a library without a version fails with an actionable message") + void versionlessLibraryFails(@TempDir Path dir) throws Exception { + writeProject(dir, """ + dependencies { + extensionLibrary("com.google.guava:guava") + } + """); + + final BuildResult result = GradleRunner.create() + .withProjectDir(dir.toFile()) + .withPluginClasspath() + .withArguments("jar", "--stacktrace") + .buildAndFail(); + + assertTrue(result.getOutput().contains("needs an explicit group and version"), + () -> "unhelpful failure:\n" + result.getOutput()); + } + + @Test + @DisplayName("the task is up to date on a second run") + void secondRunIsUpToDate(@TempDir Path dir) throws Exception { + writeProject(dir, """ + dependencies { + extensionLibrary("com.google.guava:guava:33.4.0-jre") + } + """); + + build(dir, "jar"); + final BuildResult second = build(dir, "jar"); + + assertEquals(TaskOutcome.UP_TO_DATE, outcomeOf(second)); + } + + @Test + @DisplayName("removing a library removes it from the descriptor on the next build") + void removingALibraryUpdatesTheDescriptor(@TempDir Path dir) throws Exception { + writeProject(dir, """ + dependencies { + extensionLibrary("com.google.guava:guava:33.4.0-jre") + } + """); + build(dir, "jar"); + assertTrue(descriptorFromJar(dir).contains("guava"), "setup failed, guava was never recorded"); + + // Only the build script changes; compileJava stays up to date and does not rewrite the + // descriptor. An in-place merge would silently keep the stale coordinate here. + writeProject(dir, ""); + build(dir, "jar"); + + final String json = descriptorFromJar(dir); + assertTrue(!json.contains("guava"), + () -> "the removed library is still in the descriptor:\n" + json); + } + + @Test + @DisplayName("the project version overrides the version from the annotation") + void projectVersionOverridesTheAnnotation(@TempDir Path dir) throws Exception { + writeProject(dir, """ + version = "4.5.6" + """); + + build(dir, "jar"); + + final String json = descriptorFromJar(dir); + assertTrue(json.contains("\"version\": \"4.5.6\""), + () -> "the project version was not written:\n" + json); + } + + @Test + @DisplayName("useProjectVersion = false keeps the version from the annotation") + void annotationVersionIsKeptWhenOptedOut(@TempDir Path dir) throws Exception { + writeProject(dir, """ + version = "4.5.6" + minestomExtension { + useProjectVersion = false + } + """); + + build(dir, "jar"); + + final String json = descriptorFromJar(dir); + assertTrue(json.contains("\"version\": \"1.0.0\""), + () -> "the annotation's version should have been left alone:\n" + json); + } + + @Test + @DisplayName("an unset project version leaves the annotation's alone") + void unspecifiedProjectVersionIsIgnored(@TempDir Path dir) throws Exception { + // Gradle defaults an unset version to the string "unspecified" - writing that into a + // descriptor would be worse than doing nothing. + writeProject(dir, ""); + + build(dir, "jar"); + + final String json = descriptorFromJar(dir); + assertTrue(json.contains("\"version\": \"1.0.0\""), + () -> "\"unspecified\" leaked into the descriptor:\n" + json); + } + + @Test + @DisplayName("the jar carries exactly one extension.json") + void jarHasASingleDescriptor(@TempDir Path dir) throws Exception { + writeProject(dir, """ + dependencies { + extensionLibrary("com.google.guava:guava:33.4.0-jre") + } + """); + build(dir, "jar"); + + try (ZipFile zip = new ZipFile(jarIn(dir).toFile())) { + final long count = zip.stream() + .filter(entry -> entry.getName().equals("extension.json")) + .count(); + assertEquals(1, count, "the processor's copy and the enriched one both got packaged"); + } + } + + private static Path jarIn(Path dir) throws IOException { + final Path libs = dir.resolve("build/libs"); + assertTrue(Files.isDirectory(libs), () -> "no jar was built in " + libs); + try (var files = Files.list(libs)) { + return files.filter(p -> p.getFileName().toString().endsWith(".jar")) + .findFirst() + .orElseThrow(() -> new AssertionError("no jar in " + libs)); + } + } + + private static TaskOutcome outcomeOf(BuildResult result) { + final var task = result.task(":" + MinestomExtensionPlugin.TASK_NAME); + assertNotNull(task, () -> "the plugin's task did not run:\n" + result.getOutput()); + return task.getOutcome(); + } + + private static BuildResult build(Path dir, String task) { + return GradleRunner.create() + .withProjectDir(dir.toFile()) + .withPluginClasspath() + .withArguments(task, "--stacktrace") + .build(); + } + + private static String descriptorFromJar(Path dir) throws IOException { + // Not a fixed name: a project with a version produces sample-.jar. + final Path jar = jarIn(dir); + try (ZipFile zip = new ZipFile(jar.toFile())) { + final var entry = zip.getEntry("extension.json"); + assertNotNull(entry, "the jar has no extension.json"); + try (var in = zip.getInputStream(entry)) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + } + + private static void writeProject(Path dir, String extraBuildScript) throws IOException { + writeProject(dir, extraBuildScript, ""); + } + + private static void writeProject(Path dir, String extraBuildScript, String extraAnnotation) + throws IOException { + final String processorDir = System.getProperty("processor.jar.dir"); + assertNotNull(processorDir, "processor.jar.dir was not handed over by the build"); + final File[] jars = new File(processorDir).listFiles((d, n) -> n.endsWith(".jar")); + assertTrue(jars != null && jars.length > 0, "no processor jar in " + processorDir); + + Files.writeString(dir.resolve("settings.gradle.kts"), "rootProject.name = \"sample\"\n"); + + Files.writeString(dir.resolve("build.gradle.kts"), """ + plugins { + java + id("net.onelitefeather.minestom-extensions") + } + + repositories { + maven { + name = "central" + url = uri("https://repo1.maven.org/maven2/") + } + } + + dependencies { + compileOnly(files("%s")) + annotationProcessor(files("%s")) + } + + %s + """.formatted( + jars[0].getAbsolutePath().replace("\\", "/"), + jars[0].getAbsolutePath().replace("\\", "/"), + extraBuildScript)); + + final Path source = dir.resolve("src/main/java/com/example"); + Files.createDirectories(source); + Files.writeString(source.resolve("SampleExtension.java"), """ + package com.example; + + import net.onelitefeather.minestom.extensions.processor.ExtensionInfo; + import net.onelitefeather.minestom.extensions.processor.ExternalDependency; + import net.onelitefeather.minestom.extensions.processor.Repository; + + @ExtensionInfo( + name = "Sample", + version = "1.0.0", + %s + authors = {"Tester"} + ) + public class SampleExtension { + } + """.formatted(extraAnnotation)); + } +} diff --git a/minestom-extensions-maven-plugin/build.gradle.kts b/minestom-extensions-maven-plugin/build.gradle.kts new file mode 100644 index 0000000..21e4b57 --- /dev/null +++ b/minestom-extensions-maven-plugin/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + `java-library` +} + +description = "Maven plugin feeding build-declared dependencies into extension.json" + +dependencies { + // Shared with the processor so both sides render the descriptor identically. It carries no + // dependencies of its own. + implementation(project(":minestom-extensions-processor")) + + compileOnly(libs.maven.plugin.api) + compileOnly(libs.maven.core) + compileOnly(libs.maven.plugin.annotations) + + testImplementation(platform(libs.myclium.bom)) + testImplementation(libs.junit.api) + testImplementation(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.engine) + testImplementation(libs.maven.plugin.api) + testImplementation(libs.maven.core) + testImplementation(libs.maven.plugin.annotations) +} + +// A Maven build would generate META-INF/maven/plugin.xml with maven-plugin-plugin. The Gradle +// equivalent (de.benediktritter.maven-plugin-development) calls +// ProjectDependency.getDependencyProject(), which Gradle 9 removed, and its latest release 0.4.3 +// has not been updated — so the descriptor is maintained by hand in src/main/resources instead. +// PluginDescriptorTest checks it against the mojo through reflection, so the two cannot drift: +// every documented parameter must exist as a field, and every @Parameter field must be documented. +tasks.processResources { + val pluginVersion = project.version.toString() + inputs.property("pluginVersion", pluginVersion) + filesMatching("META-INF/maven/plugin.xml") { + // A plain token, not expand(): the descriptor is full of Maven's own ${project...} + // placeholders that must survive into the published file verbatim. + filter { line -> line.replace("@pluginVersion@", pluginVersion) } + } +} + +tasks { + test { + useJUnitPlatform() + } +} diff --git a/minestom-extensions-maven-plugin/src/main/java/net/onelitefeather/minestom/extensions/maven/ExtensionDescriptorMojo.java b/minestom-extensions-maven-plugin/src/main/java/net/onelitefeather/minestom/extensions/maven/ExtensionDescriptorMojo.java new file mode 100644 index 0000000..54f13df --- /dev/null +++ b/minestom-extensions-maven-plugin/src/main/java/net/onelitefeather/minestom/extensions/maven/ExtensionDescriptorMojo.java @@ -0,0 +1,271 @@ +package net.onelitefeather.minestom.extensions.maven; + +import net.onelitefeather.minestom.extensions.processor.ExtensionDescriptor; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * Adds the dependencies declared in the POM to the {@code extension.json} generated by + * {@code minestom-extensions-processor}. + * + *

Without this, the coordinates have to be repeated in {@code @ExtensionInfo} next to versions + * the build already knows — two places to update, one of which goes stale unnoticed. + * + *

Which dependencies to record is stated explicitly. Deriving them from a scope was tried and + * rejected: everything a Minestom extension compiles against is {@code provided} — Minestom itself, + * {@code minestom-extensions}, the annotation processor — and none of those are libraries the + * extension should resolve at runtime. Guessing produced a descriptor that told the server to + * download the annotation processor. + * + *

The version may be left out, and then comes from the project's own dependencies. That is the + * point of the goal: the coordinate is named once here and versioned once in {@code + * }, instead of being repeated in the annotation. + * + *

{@code
+ * 
+ *   net.onelitefeather
+ *   minestom-extensions-maven-plugin
+ *   RELEASE_VERSION
+ *   
+ *     
+ *       
+ *       org.apache.commons:commons-lang3
+ *       
+ *       com.google.guava:guava:33.4.0-jre
+ *     
+ *   
+ *   
+ *     
+ *       describe
+ *     
+ *   
+ * 
+ * }
+ */ +@Mojo(name = "describe", defaultPhase = LifecyclePhase.PROCESS_CLASSES, threadSafe = true) +public class ExtensionDescriptorMojo extends AbstractMojo { + + /** The project being built. */ + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + /** Directory holding the compiled classes, where the processor wrote the descriptor. */ + @Parameter(defaultValue = "${project.build.outputDirectory}", readonly = true, required = true) + private File outputDirectory; + + /** + * Coordinates to record, as {@code group:artifact} or {@code group:artifact:version}. Without a + * version, it is taken from the project's dependencies. + */ + @Parameter + private List externalDependencies = new ArrayList<>(); + + /** + * Repositories the runtime should search, as {@code name=url} entries. When empty, the + * repositories declared in the POM are used. + */ + @Parameter + private Map repositories = new LinkedHashMap<>(); + + /** Whether the repositories declared in the POM are written into the descriptor. */ + @Parameter(defaultValue = "true") + private boolean inheritProjectRepositories; + + /** + * Whether the project version is written into the descriptor, overriding + * {@code @ExtensionInfo(version = ...)}. + * + *

On by default: the POM already carries the version, and a copy kept in the annotation is + * the one that goes stale. Turn it off to keep the version in the source. + */ + @Parameter(defaultValue = "true") + private boolean useProjectVersion; + + /** Skips the goal entirely. */ + @Parameter(property = "minestom.extension.skip", defaultValue = "false") + private boolean skip; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + if (skip) { + getLog().info("Skipping extension descriptor"); + return; + } + + final File file = new File(outputDirectory, "extension.json"); + if (!file.isFile()) { + if (externalDependencies.isEmpty() && !useProjectVersion) { + getLog().debug("No descriptor and nothing configured, nothing to do"); + return; + } + throw new MojoFailureException(file + " does not exist. Is " + + "minestom-extensions-processor configured in of " + + "maven-compiler-plugin, and is an entrypoint annotated with @ExtensionInfo?"); + } + + final List artifacts = resolveCoordinates(); + + final ExtensionDescriptor descriptor; + try { + descriptor = ExtensionDescriptor.fromJson(pristineDescriptor(file)); + } catch (IOException e) { + throw new MojoExecutionException("Could not read " + file, e); + } catch (IllegalArgumentException e) { + throw new MojoFailureException(file + " is not a usable extension descriptor: " + + e.getMessage(), e); + } + + // The POM is the better source for the version: keeping it in the annotation as well means + // maintaining it twice, and the copy in the source is the one that goes stale. + if (useProjectVersion && project.getVersion() != null && !project.getVersion().isBlank()) { + descriptor.version(project.getVersion()); + } + + if (artifacts.isEmpty()) { + // Nothing configured any more. The descriptor still has to be restored to what the + // processor produced, or coordinates removed from the POM would linger in it. + writeDescriptor(file, descriptor.toJson()); + getLog().debug("No external dependencies configured"); + return; + } + + // Whatever @ExtensionInfo already declared stays and wins on a clash: ExtensionDescriptor + // ignores duplicate coordinates and keeps the first repository seen for a given name. + descriptor.artifacts(artifacts); + final List entries = new ArrayList<>(); + resolveRepositories().forEach((name, url) -> + entries.add(new ExtensionDescriptor.RepositoryEntry(name, url))); + descriptor.repositories(entries); + + if (descriptor.repositories().isEmpty()) { + throw new MojoFailureException("The extension declares external dependencies but no " + + "repository to resolve them from, so the server would fail to load it. Declare " + + "a in the POM, or configure on this plugin."); + } + + writeDescriptor(file, descriptor.toJson()); + + getLog().info("Recorded " + descriptor.artifacts().size() + " external " + + (descriptor.artifacts().size() == 1 ? "dependency" : "dependencies") + + " in extension.json"); + } + + /** Writes the descriptor and records it, so the next run can tell its own output apart. */ + private void writeDescriptor(File file, String json) throws MojoExecutionException { + try { + Files.writeString(file.toPath(), json, StandardCharsets.UTF_8); + Files.writeString(lastWrittenFile().toPath(), json, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new MojoExecutionException("Could not write " + file, e); + } + } + + /** + * Returns the descriptor as the annotation processor wrote it, rather than as this goal last + * left it. + * + *

The goal writes into the same file it reads, so on a build without {@code clean} — where + * {@code compile} is up to date and the processor does not run again — it would otherwise read + * back its own output. Merging into that is not harmless: a coordinate removed from the POM + * would stay in the descriptor forever, and the server would keep downloading it. + * + *

So a pristine copy is kept beside the build output. It is refreshed whenever the file in + * the class output differs from what this goal wrote last, which is exactly the case where the + * processor has regenerated it. + */ + private String pristineDescriptor(File descriptor) throws IOException { + final String current = Files.readString(descriptor.toPath(), StandardCharsets.UTF_8); + final File lastWritten = lastWrittenFile(); + final File pristine = new File(lastWritten.getParentFile(), "pristine-descriptor.json"); + + if (lastWritten.isFile() && pristine.isFile() + && current.equals(Files.readString(lastWritten.toPath(), StandardCharsets.UTF_8))) { + // Unchanged since our last run: the processor did not regenerate it. + return Files.readString(pristine.toPath(), StandardCharsets.UTF_8); + } + + Files.writeString(pristine.toPath(), current, StandardCharsets.UTF_8); + return current; + } + + private File lastWrittenFile() throws IOException { + final File dir = new File(project.getBuild().getDirectory(), "minestom-extension"); + Files.createDirectories(dir.toPath()); + return new File(dir, "last-written-descriptor.json"); + } + + /** + * Completes each configured coordinate, filling in a missing version from the project's own + * dependencies. + * + * @throws MojoFailureException if a coordinate is malformed, or has no version and no matching + * project dependency to take one from + */ + private List resolveCoordinates() throws MojoFailureException { + final List resolved = new ArrayList<>(externalDependencies.size()); + + for (String declared : externalDependencies) { + final String coordinate = declared.trim(); + final String[] parts = coordinate.split(":"); + + if (parts.length == 3) { + resolved.add(coordinate); + continue; + } + if (parts.length != 2) { + throw new MojoFailureException("'" + coordinate + "' is not a valid coordinate. " + + "Expected group:artifact or group:artifact:version."); + } + + final String version = versionOf(parts[0], parts[1]); + if (version == null) { + throw new MojoFailureException("No version given for '" + coordinate + + "' and the project has no such dependency to take one from. Either add it " + + "to , or write the version into the coordinate."); + } + resolved.add(parts[0] + ":" + parts[1] + ":" + version); + } + return resolved; + } + + private String versionOf(String groupId, String artifactId) { + for (Artifact artifact : project.getArtifacts()) { + if (artifact.getGroupId().equals(groupId) && artifact.getArtifactId().equals(artifactId)) { + return artifact.getVersion(); + } + } + return null; + } + + private Map resolveRepositories() { + final Map resolved = new LinkedHashMap<>(); + if (inheritProjectRepositories) { + project.getRemoteArtifactRepositories().forEach(repository -> { + final String url = repository.getUrl(); + // A local path means nothing on the server. + if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) { + resolved.put(repository.getId(), url); + } + }); + } + resolved.putAll(repositories); + // Sorted so the descriptor does not churn between builds. + return new TreeMap<>(resolved); + } +} diff --git a/minestom-extensions-maven-plugin/src/main/resources/META-INF/maven/plugin.xml b/minestom-extensions-maven-plugin/src/main/resources/META-INF/maven/plugin.xml new file mode 100644 index 0000000..805f47c --- /dev/null +++ b/minestom-extensions-maven-plugin/src/main/resources/META-INF/maven/plugin.xml @@ -0,0 +1,96 @@ + + + + Minestom Extensions Maven Plugin + Adds the dependencies declared in the build to the generated extension.json + net.onelitefeather + minestom-extensions-maven-plugin + @pluginVersion@ + minestom-extension + false + true + + + describe + Adds the dependencies declared in the POM to the generated extension.json + false + true + false + false + false + true + + compile + process-classes + net.onelitefeather.minestom.extensions.maven.ExtensionDescriptorMojo + java + per-lookup + once-per-session + true + + + project + org.apache.maven.project.MavenProject + true + false + The project being built. + + + outputDirectory + java.io.File + true + false + Directory holding the compiled classes. + + + externalDependencies + java.util.List + false + true + Coordinates to record. Defaults to the provided scope dependencies. + + + repositories + java.util.Map + false + true + Repositories to record, as name=url entries. + + + inheritProjectRepositories + boolean + false + true + Whether the repositories declared in the POM are recorded. + + + useProjectVersion + boolean + false + true + Whether the project version overrides the annotation's. + + + skip + boolean + false + true + Skips the goal entirely. + + + + + + + + + + ${minestom.extension.skip} + + + + diff --git a/minestom-extensions-maven-plugin/src/test/java/net/onelitefeather/minestom/extensions/maven/ExtensionDescriptorMojoTest.java b/minestom-extensions-maven-plugin/src/test/java/net/onelitefeather/minestom/extensions/maven/ExtensionDescriptorMojoTest.java new file mode 100644 index 0000000..af6e70e --- /dev/null +++ b/minestom-extensions-maven-plugin/src/test/java/net/onelitefeather/minestom/extensions/maven/ExtensionDescriptorMojoTest.java @@ -0,0 +1,193 @@ +package net.onelitefeather.minestom.extensions.maven; + +import org.apache.maven.artifact.DefaultArtifact; +import org.apache.maven.artifact.handler.DefaultArtifactHandler; +import org.apache.maven.artifact.repository.MavenArtifactRepository; +import org.apache.maven.artifact.repository.layout.DefaultRepositoryLayout; +import org.apache.maven.model.Build; +import org.apache.maven.model.Model; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the goal directly, without a Maven build around it. + * + *

Fields are injected by reflection because that is what Maven does at runtime, and pulling in + * {@code maven-plugin-testing-harness} for six fields would cost more than it gives. + */ +class ExtensionDescriptorMojoTest { + + private static final String PROCESSOR_OUTPUT = """ + { + "name": "Sample", + "entrypoint": "com.example.SampleExtension", + "version": "1.0.0" + }"""; + + @Test + @DisplayName("a coordinate without a version takes it from the project dependencies") + void versionComesFromTheProject(@TempDir Path dir) throws Exception { + final Path classes = writeProcessorOutput(dir); + final ExtensionDescriptorMojo mojo = mojo(dir, List.of("org.apache.commons:commons-lang3")); + + mojo.execute(); + + final String json = Files.readString(classes.resolve("extension.json"), StandardCharsets.UTF_8); + assertTrue(json.contains("org.apache.commons:commons-lang3:3.17.0"), + () -> "the version was not resolved from the project:\n" + json); + } + + @Test + @DisplayName("removing the last coordinate restores the processor's descriptor") + void removingCoordinatesRestoresTheDescriptor(@TempDir Path dir) throws Exception { + final Path classes = writeProcessorOutput(dir); + final Path descriptor = classes.resolve("extension.json"); + + mojo(dir, List.of("org.apache.commons:commons-lang3")).execute(); + assertTrue(Files.readString(descriptor).contains("commons-lang3"), "setup failed"); + + // Second run with nothing configured, and without the processor having run again — which is + // what an incremental build looks like. The stale coordinate must not survive. + mojo(dir, List.of()).execute(); + + final String json = Files.readString(descriptor, StandardCharsets.UTF_8); + assertAll( + () -> assertFalse(json.contains("commons-lang3"), + () -> "the removed coordinate is still there:\n" + json), + () -> assertFalse(json.contains("externalDependencies"), + () -> "the empty section should be gone entirely:\n" + json), + () -> assertTrue(json.contains("\"name\": \"Sample\""), + () -> "the processor's own fields were lost:\n" + json)); + } + + @Test + @DisplayName("running twice with the same configuration is idempotent") + void repeatedRunsAreIdempotent(@TempDir Path dir) throws Exception { + final Path descriptor = writeProcessorOutput(dir).resolve("extension.json"); + + mojo(dir, List.of("org.apache.commons:commons-lang3")).execute(); + final String first = Files.readString(descriptor, StandardCharsets.UTF_8); + mojo(dir, List.of("org.apache.commons:commons-lang3")).execute(); + + assertTrue(first.equals(Files.readString(descriptor, StandardCharsets.UTF_8)), + "the second run changed the descriptor"); + } + + @Test + @DisplayName("an unknown coordinate without a version fails with an actionable message") + void unknownCoordinateFails(@TempDir Path dir) throws Exception { + writeProcessorOutput(dir); + final ExtensionDescriptorMojo mojo = mojo(dir, List.of("com.unknown:missing")); + + final MojoFailureException failure = assertThrows(MojoFailureException.class, mojo::execute); + assertTrue(failure.getMessage().contains("no such dependency"), + () -> "unhelpful message: " + failure.getMessage()); + } + + @Test + @DisplayName("the project version overrides the version from the annotation") + void projectVersionOverridesTheAnnotation(@TempDir Path dir) throws Exception { + final Path descriptor = writeProcessorOutput(dir).resolve("extension.json"); + final ExtensionDescriptorMojo mojo = mojo(dir, List.of()); + set(mojo, "useProjectVersion", true); + + mojo.execute(); + + final String json = Files.readString(descriptor, StandardCharsets.UTF_8); + assertAll( + () -> assertTrue(json.contains("\"version\": \"9.9.9\""), + () -> "the project version was not written:\n" + json), + () -> assertFalse(json.contains("1.0.0"), + () -> "the annotation's version is still there:\n" + json)); + } + + @Test + @DisplayName("useProjectVersion=false keeps the version from the annotation") + void annotationVersionIsKeptWhenOptedOut(@TempDir Path dir) throws Exception { + final Path descriptor = writeProcessorOutput(dir).resolve("extension.json"); + final ExtensionDescriptorMojo mojo = mojo(dir, List.of()); + set(mojo, "useProjectVersion", false); + + mojo.execute(); + + assertTrue(Files.readString(descriptor, StandardCharsets.UTF_8).contains("\"version\": \"1.0.0\""), + "the annotation's version should have been left alone"); + } + + @Test + @DisplayName("a malformed coordinate fails") + void malformedCoordinateFails(@TempDir Path dir) throws Exception { + writeProcessorOutput(dir); + final ExtensionDescriptorMojo mojo = mojo(dir, List.of("not-a-coordinate")); + + assertThrows(MojoFailureException.class, mojo::execute); + } + + @Test + @DisplayName("external dependencies without any repository fail rather than ship a broken descriptor") + void missingRepositoryFails(@TempDir Path dir) throws Exception { + writeProcessorOutput(dir); + final ExtensionDescriptorMojo mojo = mojo(dir, List.of("org.apache.commons:commons-lang3")); + set(mojo, "inheritProjectRepositories", false); + + final MojoFailureException failure = assertThrows(MojoFailureException.class, mojo::execute); + assertTrue(failure.getMessage().contains("no repository"), + () -> "unhelpful message: " + failure.getMessage()); + } + + private static Path writeProcessorOutput(Path dir) throws Exception { + final Path classes = dir.resolve("target/classes"); + Files.createDirectories(classes); + Files.writeString(classes.resolve("extension.json"), PROCESSOR_OUTPUT, StandardCharsets.UTF_8); + return classes; + } + + private static ExtensionDescriptorMojo mojo(Path dir, List coordinates) throws Exception { + final Build build = new Build(); + build.setDirectory(dir.resolve("target").toString()); + build.setOutputDirectory(dir.resolve("target/classes").toString()); + + final Model model = new Model(); + model.setBuild(build); + model.setVersion("9.9.9"); + final MavenProject project = new MavenProject(model); + project.setArtifacts(Set.of(artifact("org.apache.commons", "commons-lang3", "3.17.0"))); + project.setRemoteArtifactRepositories(List.of(new MavenArtifactRepository( + "central", "https://repo1.maven.org/maven2/", new DefaultRepositoryLayout(), null, null))); + + final ExtensionDescriptorMojo mojo = new ExtensionDescriptorMojo(); + set(mojo, "project", project); + set(mojo, "outputDirectory", dir.resolve("target/classes").toFile()); + set(mojo, "externalDependencies", coordinates); + set(mojo, "repositories", new java.util.LinkedHashMap()); + set(mojo, "inheritProjectRepositories", true); + set(mojo, "skip", false); + return mojo; + } + + private static org.apache.maven.artifact.Artifact artifact(String group, String name, String version) { + return new DefaultArtifact(group, name, version, "provided", "jar", null, + new DefaultArtifactHandler("jar")); + } + + private static void set(Object target, String field, Object value) throws Exception { + final Field declared = target.getClass().getDeclaredField(field); + declared.setAccessible(true); + declared.set(target, value); + } +} diff --git a/minestom-extensions-maven-plugin/src/test/java/net/onelitefeather/minestom/extensions/maven/PluginDescriptorTest.java b/minestom-extensions-maven-plugin/src/test/java/net/onelitefeather/minestom/extensions/maven/PluginDescriptorTest.java new file mode 100644 index 0000000..5aedb0d --- /dev/null +++ b/minestom-extensions-maven-plugin/src/test/java/net/onelitefeather/minestom/extensions/maven/PluginDescriptorTest.java @@ -0,0 +1,160 @@ +package net.onelitefeather.minestom.extensions.maven; + +import org.apache.maven.plugin.AbstractMojo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Keeps the hand-written {@code META-INF/maven/plugin.xml} in sync with {@link + * ExtensionDescriptorMojo}. + * + *

A Maven build would generate that file from the annotations. This module is built with Gradle, + * where the equivalent plugin calls an API Gradle 9 removed, so the descriptor is maintained by + * hand — and a hand-written descriptor drifts. The failure mode is unpleasant: Maven reports a + * parameter as unknown, or silently never injects it, and the goal misbehaves in the user's build + * rather than in ours. These assertions move that failure here. + * + *

The mojo's own annotations cannot be used for the comparison: Maven's {@code @Mojo} and + * {@code @Parameter} are {@code RetentionPolicy.CLASS}, so they do not exist at runtime. The check + * therefore runs against the declared fields, which is what Maven ultimately injects into anyway. + */ +class PluginDescriptorTest { + + private static final String DESCRIPTOR = "/META-INF/maven/plugin.xml"; + + @Test + @DisplayName("every injectable field of the mojo is declared in plugin.xml, and vice versa") + void parametersMatchTheMojoFields() throws Exception { + final Set declared = declaredParameters(); + + final Set fields = new TreeSet<>(); + for (Field field : ExtensionDescriptorMojo.class.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers())) { + fields.add(field.getName()); + } + } + + assertAll( + () -> assertEquals(fields, declared, + "plugin.xml and the mojo's fields disagree — add, rename or remove the " + + "matching and entry"), + () -> assertFalse(fields.isEmpty(), "the mojo declares no fields at all")); + } + + @Test + @DisplayName("the declared parameter types match the field types") + void parameterTypesMatchTheMojo() throws Exception { + final NodeList parameters = descriptor().getElementsByTagName("parameter"); + assertTrue(parameters.getLength() > 0, "no parameters declared"); + + for (int i = 0; i < parameters.getLength(); i++) { + final Element parameter = (Element) parameters.item(i); + final String name = text(parameter, "name"); + final String type = text(parameter, "type"); + + final Field field = ExtensionDescriptorMojo.class.getDeclaredField(name); + assertEquals(field.getType().getName(), type, + () -> "declared type of '" + name + "' does not match the field"); + } + } + + @Test + @DisplayName("every parameter also has a configuration entry, which is what Maven injects from") + void everyParameterHasAConfigurationEntry() throws Exception { + final Element configuration = + (Element) descriptor().getElementsByTagName("configuration").item(0); + assertNotNull(configuration, "plugin.xml has no block"); + + final Set configured = new TreeSet<>(); + final NodeList children = configuration.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i) instanceof Element element) { + configured.add(element.getTagName()); + } + } + + assertEquals(declaredParameters(), configured, + " and disagree — Maven would not inject the difference"); + } + + @Test + @DisplayName("the implementation class exists and is a mojo") + void implementationClassIsUsable() throws Exception { + final Element mojo = (Element) descriptor().getElementsByTagName("mojo").item(0); + final String implementation = text(mojo, "implementation"); + + final Class type = Class.forName(implementation); + assertAll( + () -> assertTrue(AbstractMojo.class.isAssignableFrom(type), + implementation + " does not extend AbstractMojo"), + () -> assertFalse(Modifier.isAbstract(type.getModifiers()), + implementation + " is abstract and could not be instantiated by Maven"), + () -> assertNotNull(type.getConstructor(), + implementation + " has no public no-arg constructor")); + } + + @Test + @DisplayName("goal and phase are set to the documented values") + void goalAndPhaseAreDeclared() throws Exception { + final Element mojo = (Element) descriptor().getElementsByTagName("mojo").item(0); + + assertAll( + () -> assertEquals("describe", text(mojo, "goal")), + // process-classes runs after compile, so the processor has written the descriptor. + () -> assertEquals("process-classes", text(mojo, "phase")), + // provided-scope artifacts are only populated once dependencies are resolved. + () -> assertEquals("compile", text(mojo, "requiresDependencyResolution")), + () -> assertEquals("true", text(mojo, "threadSafe"))); + } + + @Test + @DisplayName("the version placeholder is filtered in by the build") + void versionIsFiltered() throws Exception { + final String version = text(descriptor().getDocumentElement(), "version"); + assertAll( + () -> assertNotNull(version), + () -> assertFalse(version.contains("@"), + () -> "the version token was not replaced: " + version), + () -> assertFalse(version.contains("${"), + () -> "the version still holds a placeholder: " + version)); + } + + private static Set declaredParameters() throws Exception { + final NodeList nodes = descriptor().getElementsByTagName("parameter"); + final Set names = new LinkedHashSet<>(); + for (int i = 0; i < nodes.getLength(); i++) { + names.add(text((Element) nodes.item(i), "name")); + } + return new TreeSet<>(names); + } + + private static Document descriptor() throws Exception { + try (InputStream in = PluginDescriptorTest.class.getResourceAsStream(DESCRIPTOR)) { + assertNotNull(in, "missing " + DESCRIPTOR + " on the test classpath"); + return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(in); + } + } + + /** First matching descendant, which is unambiguous for the elements looked up here. */ + private static String text(Element parent, String tag) { + final NodeList nodes = parent.getElementsByTagName(tag); + return nodes.getLength() == 0 ? null : nodes.item(0).getTextContent().trim(); + } +} diff --git a/minestom-extensions-processor/build.gradle.kts b/minestom-extensions-processor/build.gradle.kts new file mode 100644 index 0000000..00ee9ac --- /dev/null +++ b/minestom-extensions-processor/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + `java-library` +} + +description = "Annotation processor generating extension.json for minestom extensions" + +// This module is intentionally dependency-free at runtime: it uses only the JDK's +// javax.annotation.processing / javax.lang.model APIs. No Gson, no Minestom, no auto-service. +// The processor registers itself through META-INF/services. +dependencies { + // Compile testing uses the JDK's own javax.tools.ToolProvider.getSystemJavaCompiler(), + // so no external compile-testing library is required. + testImplementation(platform(libs.myclium.bom)) + testImplementation(libs.junit.api) + testImplementation(libs.junit.platform.launcher) + // Only used to parse the generated extension.json back, so the tests assert on the parsed + // structure instead of on brittle string equality. Never a dependency of the processor itself. + testImplementation(libs.gson) + testRuntimeOnly(libs.junit.engine) +} + +// The descriptor contract lives in minestom-extensions, where a test deserializes it into the real +// DiscoveredExtension. Copying it in (rather than duplicating it) is what keeps the two ends from +// drifting apart: this module asserts the processor reproduces the file, the other asserts Gson +// still reads every field of it. A plain file reference, not a project dependency - this module +// must stay independent of minestom-extensions. +val descriptorContract by tasks.registering(Copy::class) { + description = "Copies the shared extension.json contract from the minestom-extensions module" + from(rootProject.layout.projectDirectory + .file("minestom-extensions/src/test/resources/extension-descriptor-contract.json")) + into(layout.buildDirectory.dir("generated/test-resources/contract")) +} + +sourceSets { + test { + resources.srcDir(descriptorContract) + } +} + +tasks { + test { + useJUnitPlatform() + } +} diff --git a/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionDescriptor.java b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionDescriptor.java new file mode 100644 index 0000000..ce83f48 --- /dev/null +++ b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionDescriptor.java @@ -0,0 +1,305 @@ +package net.onelitefeather.minestom.extensions.processor; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The content of an {@code extension.json}, and the single place that renders it. + * + *

This is shared API rather than an internal detail of the processor: the Gradle and Maven + * plugins read a descriptor the processor already generated, add the external dependencies declared + * in the build, and write it back. Routing every writer through this class is what guarantees they + * all produce byte-identical formatting - in particular the escaping of non-ASCII characters, which + * a general-purpose JSON library would not do and whose absence corrupts author names on any build + * that does not run in UTF-8. + * + *

Deliberately dependency-free, like the rest of this module. Reading an existing descriptor is + * not offered here; callers that need it (the plugins) already have a JSON parser available and + * populate an instance themselves. + * + *

Instances are mutable and not thread-safe. + */ +public final class ExtensionDescriptor { + + private String name; + private String entrypoint; + private String version; + private final List authors = new ArrayList<>(); + private final List dependencies = new ArrayList<>(); + private final List repositories = new ArrayList<>(); + private final List artifacts = new ArrayList<>(); + + /** + * A Maven repository the runtime should search for external dependencies. + * + * @param name identifier of the repository, must not be blank + * @param url base url of the repository + */ + public record RepositoryEntry(String name, String url) { + + /** + * @param name identifier of the repository + * @param url base url of the repository + */ + public RepositoryEntry { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(url, "url"); + } + } + + /** + * Reads a descriptor back, typically one the annotation processor just generated. + * + *

Used by the Gradle and Maven plugins, which add the dependencies declared in the build to + * what the annotation already stated. Anything not understood is rejected rather than dropped + * silently - losing a field on a rewrite would only surface as a runtime failure on the server. + * + * @param json the descriptor content + * @return the parsed descriptor + * @throws IllegalArgumentException if the content is malformed or carries an unknown field + */ + public static ExtensionDescriptor fromJson(String json) { + final Map root = JsonReader.parseObject(json); + + for (String key : root.keySet()) { + if (!KNOWN_FIELDS.contains(key)) { + throw new IllegalArgumentException("Unknown field '" + key + "' in extension.json. " + + "Known fields are " + KNOWN_FIELDS + "."); + } + } + + final ExtensionDescriptor descriptor = new ExtensionDescriptor() + .name(string(root, "name")) + .entrypoint(string(root, "entrypoint")) + .version(string(root, "version")) + .authors(stringList(root, "authors")) + .dependencies(stringList(root, "dependencies")); + + final Object external = root.get("externalDependencies"); + if (external != null) { + if (!(external instanceof Map map)) { + throw new IllegalArgumentException("'externalDependencies' must be an object"); + } + @SuppressWarnings("unchecked") + final Map externals = (Map) map; + + descriptor.artifacts(stringList(externals, "artifacts")); + + final Object repositories = externals.get("repositories"); + if (repositories != null) { + final List entries = new ArrayList<>(); + for (Object element : list(repositories, "repositories")) { + if (!(element instanceof Map raw)) { + throw new IllegalArgumentException("Each repository must be an object"); + } + @SuppressWarnings("unchecked") + final Map repository = (Map) raw; + entries.add(new RepositoryEntry( + string(repository, "name"), string(repository, "url"))); + } + descriptor.repositories(entries); + } + } + return descriptor; + } + + private static final Set KNOWN_FIELDS = Set.of( + "name", "entrypoint", "version", "authors", "dependencies", "externalDependencies", "meta"); + + private static String string(Map object, String key) { + final Object value = object.get(key); + if (value == null) { + return null; + } + if (!(value instanceof String text)) { + throw new IllegalArgumentException("'" + key + "' must be a string"); + } + return text; + } + + private static List stringList(Map object, String key) { + final Object value = object.get(key); + if (value == null) { + return List.of(); + } + final List values = new ArrayList<>(); + for (Object element : list(value, key)) { + if (!(element instanceof String text)) { + throw new IllegalArgumentException("'" + key + "' must contain only strings"); + } + values.add(text); + } + return values; + } + + private static List list(Object value, String key) { + if (!(value instanceof List values)) { + throw new IllegalArgumentException("'" + key + "' must be an array"); + } + return values; + } + + /** + * @param name the extension name + * @return this descriptor + */ + public ExtensionDescriptor name(String name) { + this.name = name; + return this; + } + + /** + * @param entrypoint binary name of the class extending {@code Extension} + * @return this descriptor + */ + public ExtensionDescriptor entrypoint(String entrypoint) { + this.entrypoint = entrypoint; + return this; + } + + /** + * @param version the extension version, or {@code null} to omit the field + * @return this descriptor + */ + public ExtensionDescriptor version(String version) { + this.version = version; + return this; + } + + /** + * @param authors the authors to add + * @return this descriptor + */ + public ExtensionDescriptor authors(List authors) { + this.authors.addAll(authors); + return this; + } + + /** + * @param dependencies names of other extensions this one depends on + * @return this descriptor + */ + public ExtensionDescriptor dependencies(List dependencies) { + this.dependencies.addAll(dependencies); + return this; + } + + /** + * Adds repositories, ignoring any whose name is already present. + * + * @param repositories the repositories to add + * @return this descriptor + */ + public ExtensionDescriptor repositories(List repositories) { + final Set known = new LinkedHashSet<>(); + this.repositories.forEach(existing -> known.add(existing.name())); + for (RepositoryEntry repository : repositories) { + if (known.add(repository.name())) { + this.repositories.add(repository); + } + } + return this; + } + + /** + * Adds Maven coordinates, ignoring duplicates. + * + * @param artifacts coordinates as {@code group:artifact:version} + * @return this descriptor + */ + public ExtensionDescriptor artifacts(List artifacts) { + for (String artifact : artifacts) { + if (!this.artifacts.contains(artifact)) { + this.artifacts.add(artifact); + } + } + return this; + } + + /** + * @return the extension name, or {@code null} if unset + */ + public String name() { + return name; + } + + /** + * @return the entrypoint, or {@code null} if unset + */ + public String entrypoint() { + return entrypoint; + } + + /** + * @return the coordinates currently declared + */ + public List artifacts() { + return List.copyOf(artifacts); + } + + /** + * @return the repositories currently declared + */ + public List repositories() { + return List.copyOf(repositories); + } + + /** + * Renders the descriptor. + * + *

Empty collections are omitted rather than written as empty arrays: {@code + * DiscoveredExtension} defaults every missing field, so the shorter file is equivalent and + * easier to read inside a jar. + * + * @return the {@code extension.json} content, pretty printed and pure ASCII + * @throws IllegalStateException if name or entrypoint is missing + */ + public String toJson() { + if (name == null || name.isBlank()) { + throw new IllegalStateException("An extension descriptor needs a name"); + } + if (entrypoint == null || entrypoint.isBlank()) { + throw new IllegalStateException("An extension descriptor needs an entrypoint"); + } + + final JsonWriter json = new JsonWriter(); + json.beginObject(); + json.stringMember("name", name); + json.stringMember("entrypoint", entrypoint); + + if (version != null) { + json.stringMember("version", version); + } + if (!authors.isEmpty()) { + json.arrayMember("authors", authors); + } + if (!dependencies.isEmpty()) { + json.arrayMember("dependencies", dependencies); + } + + if (!repositories.isEmpty() || !artifacts.isEmpty()) { + json.name("externalDependencies").beginObject(); + if (!repositories.isEmpty()) { + json.name("repositories").beginArray(); + for (RepositoryEntry repository : repositories) { + json.beginObject() + .stringMember("name", repository.name()) + .stringMember("url", repository.url()) + .endObject(); + } + json.endArray(); + } + if (!artifacts.isEmpty()) { + json.arrayMember("artifacts", artifacts); + } + json.endObject(); + } + + json.endObject(); + return json.toString(); + } +} diff --git a/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfo.java b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfo.java new file mode 100644 index 0000000..3966ff6 --- /dev/null +++ b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfo.java @@ -0,0 +1,181 @@ +package net.onelitefeather.minestom.extensions.processor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares the entrypoint class of a Minestom extension and every piece of metadata that ends up + * in the generated {@code extension.json}. + * + *

Annotate the class that extends {@code net.minestom.server.extensions.Extension} with this + * annotation and the {@link ExtensionInfoProcessor} writes an {@code extension.json} into the root + * of the compiled output (and therefore into the root of the resulting jar), which is exactly where + * {@code ExtensionManager} looks for it at runtime. + * + *

The entrypoint is never declared

+ * There is deliberately no {@code entrypoint()} element. The entrypoint is always the fully + * qualified name of the annotated class, derived by the processor. That removes the single most + * common source of a broken {@code extension.json}: a hand written entrypoint string that silently + * drifts away from the class it is supposed to point at after a rename or a package move. + * + *

Why external dependencies are modelled flat

+ * {@link #repositories()} and {@link #externalDependencies()} are two independent flat arrays of + * annotations instead of one nested {@code @ExternalDependencies(...)} wrapper. In the generated + * JSON both end up inside the single {@code externalDependencies} object, but for the user the flat + * form reads better: an extension that only needs artifacts from Maven Central never has to mention + * a wrapper type at all, and both lists stay at the same indentation level as {@link #authors()} + * and {@link #dependencies()} instead of being pushed one level deeper. The nesting that the + * runtime format requires is an implementation detail of the JSON, not something the author of an + * extension should have to type. + * + *

Example

+ *
{@code
+ * @ExtensionInfo(
+ *         name = "MyExtension",
+ *         version = "1.0.0",
+ *         authors = {"Alice", "Bob"},
+ *         dependencies = {"SomeOtherExtension"},
+ *         repositories = {
+ *                 @Repository(name = "central", url = "https://repo1.maven.org/maven2/")
+ *         },
+ *         externalDependencies = {
+ *                 @ExternalDependency("com.google.guava:guava:33.4.0-jre")
+ *         }
+ * )
+ * public final class MyExtension extends Extension {
+ *
+ *     @Override
+ *     public void initialize() {
+ *         // ...
+ *     }
+ * }
+ * }
+ * + * produces + * + *
{@code
+ * {
+ *   "name": "MyExtension",
+ *   "entrypoint": "com.example.MyExtension",
+ *   "version": "1.0.0",
+ *   "authors": [
+ *     "Alice",
+ *     "Bob"
+ *   ],
+ *   "dependencies": [
+ *     "SomeOtherExtension"
+ *   ],
+ *   "externalDependencies": {
+ *     "repositories": [
+ *       {
+ *         "name": "central",
+ *         "url": "https://repo1.maven.org/maven2/"
+ *       }
+ *     ],
+ *     "artifacts": [
+ *       "com.google.guava:guava:33.4.0-jre"
+ *     ]
+ *   }
+ * }
+ * }
+ * + *

Constraints checked at compile time

+ *
    + *
  • Only one class per compilation may carry this annotation - an + * {@code extension.json} has exactly one entrypoint.
  • + *
  • {@link #name()} must match {@code [A-Za-z][_A-Za-z0-9]+}.
  • + *
  • The annotated type must be a non-abstract class with a no-arg constructor, and it must + * not be an inner (non-static nested) class - {@code ExtensionManager} instantiates it + * reflectively.
  • + *
  • Repository URLs must start with {@code http://} or {@code https://}.
  • + *
+ * + * @see ExtensionInfoProcessor + * @see Repository + * @see ExternalDependency + * @since 2.0.0 + */ +@Documented +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +public @interface ExtensionInfo { + + /** + * The unique name of the extension. + * + *

Must match {@code [A-Za-z][_A-Za-z0-9]+}: it has to start with a letter, may only contain + * letters, digits and underscores, and has to be at least two characters long. A value that + * does not match fails the build, because the runtime would reject the extension with + * {@code INVALID_NAME}. + * + *

Can be overridden at compile time with {@code -Aminestom.extension.name=...}, which is + * useful when the name has to be injected by the build instead of being hardcoded. + * + * @return the extension name + */ + String name(); + + /** + * The version of the extension, for example {@code "1.0.0"}. + * + *

Defaults to the empty string. When it is left empty and no + * {@code -Aminestom.extension.version=...} compiler option is given, the processor emits a + * warning and omits the field; the runtime then reports the version as {@code "Unspecified"}. + * The usual setup is to leave this element empty and let the build pass the project version via + * {@code -Aminestom.extension.version=$version}, so that the version lives in exactly one place. + * + * @return the extension version, or the empty string to fall back to the compiler option + */ + String version() default ""; + + /** + * The people who wrote this extension, purely informational. + * + *

Omitted from the generated JSON when empty. + * + * @return the author names + */ + String[] authors() default {}; + + /** + * Names of other extensions that must be loaded before this one. + * + *

Each entry is the {@link #name()} of another extension, not a Maven coordinate - use + * {@link #externalDependencies()} for those. The runtime refuses to load an extension whose + * declared dependency is missing, and it wires the class loader of every dependency as a parent + * of this extension's class loader. Duplicate entries produce a warning and are collapsed. + * + *

Omitted from the generated JSON when empty. + * + * @return the names of the extensions this one depends on + */ + String[] dependencies() default {}; + + /** + * Maven artifacts that are downloaded at startup and added to this extension's class loader. + * + *

Every entry is a single Maven coordinate wrapped in {@link ExternalDependency}, for example + * {@code @ExternalDependency("com.google.guava:guava:33.4.0-jre")}. They are resolved against + * Maven Central plus whatever is listed in {@link #repositories()}. + * + *

Ends up as {@code externalDependencies.artifacts} in the generated JSON; the whole + * {@code externalDependencies} object is omitted when neither an artifact nor a repository is + * declared. + * + * @return the Maven coordinates to resolve at runtime + */ + ExternalDependency[] externalDependencies() default {}; + + /** + * Additional Maven repositories used to resolve {@link #externalDependencies()}. + * + *

Ends up as {@code externalDependencies.repositories} in the generated JSON. URLs must start + * with {@code http://} or {@code https://}, otherwise the build fails. + * + * @return the repositories to resolve external dependencies from + */ + Repository[] repositories() default {}; +} diff --git a/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfoProcessor.java b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfoProcessor.java new file mode 100644 index 0000000..c11031b --- /dev/null +++ b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfoProcessor.java @@ -0,0 +1,543 @@ +package net.onelitefeather.minestom.extensions.processor; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.NestingKind; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.ElementFilter; +import javax.tools.Diagnostic; +import javax.tools.FileObject; +import javax.tools.StandardLocation; +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Generates the {@code extension.json} descriptor of a Minestom extension from a single + * {@link ExtensionInfo} annotated class, in the spirit of the {@code plugin.yml} generators known + * from the Bukkit ecosystem. + * + *

Where the file ends up

+ * The descriptor is written to {@link StandardLocation#CLASS_OUTPUT} with an empty package name, + * which puts it at the root of the compiled output directory and therefore at the root of the + * resulting jar. That is exactly where {@code ExtensionManager} looks for it: it opens the jar as a + * zip and reads the entry named {@code extension.json}. + * + *

Compiler options

+ * + * + * + * + * + * + * + * + * + * + * + *
Supported {@code -A} options
OptionEffect
{@code minestom.extension.version}Overrides {@link ExtensionInfo#version()}. The common setup: leave the annotation + * element empty and pass the build's project version.
{@code minestom.extension.name}Overrides {@link ExtensionInfo#name()}. The override is validated against the same + * name pattern as the annotation element.
+ * + *

With Gradle both are passed like this: + * + *

{@code
+ * tasks.withType().configureEach {
+ *     options.compilerArgs.add("-Aminestom.extension.version=${project.version}")
+ * }
+ * }
+ * + *

Diagnostics

+ * Errors abort the build; warnings do not. The processor reports an error when more than one class + * is annotated, when the name does not match {@code [A-Za-z][_A-Za-z0-9]+}, when the annotated type + * cannot be instantiated reflectively by {@code ExtensionManager}, when it does not extend + * {@code net.minestom.server.extensions.Extension} (only checked when that class is on the compile + * classpath), and when a repository declaration is unusable. It warns about a missing version and + * about duplicate extension dependencies. + * + * @see ExtensionInfo + * @since 2.0.0 + */ +@SupportedAnnotationTypes("net.onelitefeather.minestom.extensions.processor.ExtensionInfo") +public final class ExtensionInfoProcessor extends AbstractProcessor { + + /** + * Compiler option that overrides {@link ExtensionInfo#version()}: + * {@code -Aminestom.extension.version=1.2.3}. + */ + public static final String OPTION_VERSION = "minestom.extension.version"; + + /** + * Compiler option that overrides {@link ExtensionInfo#name()}: + * {@code -Aminestom.extension.name=MyExtension}. + */ + public static final String OPTION_NAME = "minestom.extension.name"; + + /** + * The name of the generated resource, relative to the root of the class output. + */ + public static final String OUTPUT_FILE = "extension.json"; + + /** + * Fully qualified name of the class every entrypoint has to extend. Referenced by name only: + * the processor must never load Minestom classes, see {@link #EXTENSION_NAME_REGEX}. + */ + private static final String EXTENSION_CLASS = "net.minestom.server.extensions.Extension"; + + /** + * Intentional duplicate of {@code net.minestom.server.extensions.DiscoveredExtension#NAME_REGEX}. + * + *

The regex is copied instead of referenced because this module must not depend on + * {@code net.onelitefeather:minestom-extensions}. An annotation processor is put on the + * processor path of every project that uses it, so a dependency here would drag + * Minestom, its Gson and its SLF4J onto the processor path of every single extension build. + * Keep this constant in sync with {@code DiscoveredExtension.NAME_REGEX} - if they ever drift + * apart, the runtime one wins and the extension is rejected with {@code INVALID_NAME}. + */ + private static final String EXTENSION_NAME_REGEX = "[A-Za-z][_A-Za-z0-9]+"; + + private static final Pattern NAME_PATTERN = Pattern.compile(EXTENSION_NAME_REGEX); + + /** {@code group:artifact:version} with an optional fourth {@code :classifier} segment. */ + private static final Pattern COORDINATE_PATTERN = + Pattern.compile("[^:\\s]+:[^:\\s]+:[^:\\s]+(:[^:\\s]+)?"); + + /** The single annotated entrypoint collected over all rounds, or {@code null} if there is none. */ + private TypeElement entrypoint; + + /** Set once a duplicate entrypoint was reported, which suppresses the file generation. */ + private boolean duplicateReported; + + /** Guards against writing the descriptor twice, e.g. when {@code process} is invoked again. */ + private boolean written; + + /** + * Creates a new processor. Invoked reflectively by the compiler through the + * {@code META-INF/services/javax.annotation.processing.Processor} registration. + */ + public ExtensionInfoProcessor() { + // Annotation processors must offer a public no-arg constructor. + } + + /** + * {@inheritDoc} + * + * @return always the latest source version the running compiler supports, so the processor never + * emits a "source version not supported" warning after a JDK upgrade + */ + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + /** + * {@inheritDoc} + * + * @return {@link #OPTION_VERSION} and {@link #OPTION_NAME} + */ + @Override + public Set getSupportedOptions() { + return Set.of(OPTION_VERSION, OPTION_NAME); + } + + /** + * Collects the annotated entrypoint during the regular rounds and writes {@code extension.json} + * in the final round. + * + *

The descriptor can only be written once every round has been seen: annotation processing is + * iterative, and a later round may still contribute the annotated class (for instance when it is + * itself generated by another processor). Collecting first and writing in the round where + * {@link RoundEnvironment#processingOver()} is {@code true} also guarantees that the duplicate + * check sees every candidate. + * + * @param annotations the annotation types requested to be processed + * @param roundEnv the environment of the current round + * @return always {@code true}; {@link ExtensionInfo} and its nested annotation types are fully + * consumed here and no other processor needs to see them + */ + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + if (!roundEnv.processingOver()) { + collect(roundEnv); + return true; + } + + if (entrypoint != null && !duplicateReported && !written) { + written = true; + generate(entrypoint); + } + return true; + } + + /** Remembers the annotated type of this round and rejects any additional one. */ + private void collect(RoundEnvironment roundEnv) { + for (Element element : roundEnv.getElementsAnnotatedWith(ExtensionInfo.class)) { + // @ExtensionInfo is @Target(TYPE), so the cast is always safe. + final TypeElement type = (TypeElement) element; + if (entrypoint == null) { + entrypoint = type; + continue; + } + + duplicateReported = true; + error("Found more than one class annotated with @ExtensionInfo ('" + + entrypoint.getQualifiedName() + "' and '" + type.getQualifiedName() + + "'). An extension.json describes exactly one entrypoint, so only a single class " + + "per compilation may be annotated.", type); + } + } + + /** Validates the entrypoint and, if it is sound, writes the descriptor. */ + private void generate(TypeElement type) { + final ExtensionInfo info = type.getAnnotation(ExtensionInfo.class); + if (info == null) { + // Cannot happen: the element was collected through exactly this annotation. + return; + } + + boolean valid = validateEntrypointShape(type); + valid &= validateSuperclass(type); + + final String name = resolveName(info, type); + valid &= name != null; + + final List dependencies = resolveDependencies(info, type); + final List artifacts = new ArrayList<>(); + valid &= collectArtifacts(info, type, artifacts); + + final List repositories = new ArrayList<>(List.of(info.repositories())); + valid &= validateRepositories(repositories, type); + + if (!valid) { + return; + } + + // The binary name, not the canonical one: ExtensionManager resolves the entrypoint with + // Class.forName(), which expects a nested class as 'com.example.Outer$Inner'. + final String entrypointName = processingEnv.getElementUtils().getBinaryName(type).toString(); + + final String json = render( + name, + entrypointName, + resolveVersion(info, type), + List.of(info.authors()), + dependencies, + repositories, + artifacts); + + write(json, type); + } + + /** + * Checks everything {@code ExtensionManager} needs to instantiate the entrypoint reflectively. + * + *

Mirrors {@code ExtensionManager#loadExtension}, which does + * {@code Class.forName(...).asSubclass(Extension.class).getDeclaredConstructor()} followed by + * {@code constructor.setAccessible(true)} and {@code newInstance()}. Because of the + * {@code setAccessible(true)} a non-public class or constructor still works at runtime, so those + * only produce a warning - everything that genuinely breaks instantiation is an error. + */ + private boolean validateEntrypointShape(TypeElement type) { + boolean valid = true; + + if (type.getKind() != ElementKind.CLASS) { + error("@ExtensionInfo can only be applied to a class, but '" + type.getQualifiedName() + + "' is a " + type.getKind().name().toLowerCase() + + ". The entrypoint has to be a class extending " + EXTENSION_CLASS + ".", type); + return false; + } + + if (type.getModifiers().contains(Modifier.ABSTRACT)) { + error("The extension entrypoint '" + type.getQualifiedName() + + "' must not be abstract: ExtensionManager instantiates it reflectively.", type); + valid = false; + } + + final NestingKind nesting = type.getNestingKind(); + if (nesting != NestingKind.TOP_LEVEL) { + if (nesting != NestingKind.MEMBER) { + error("The extension entrypoint '" + type.getQualifiedName() + + "' must be a top-level or static nested class, not a local or anonymous one.", + type); + valid = false; + } else if (!type.getModifiers().contains(Modifier.STATIC)) { + error("The extension entrypoint '" + type.getQualifiedName() + + "' is an inner class. Inner classes have no no-arg constructor (they capture " + + "their enclosing instance) and cannot be instantiated by ExtensionManager. " + + "Make it static or move it to its own file.", type); + valid = false; + } + } + + final List constructors = + ElementFilter.constructorsIn(type.getEnclosedElements()); + if (constructors.isEmpty()) { + // Only the implicit default constructor exists, which is always a valid no-arg one. + if (!type.getModifiers().contains(Modifier.PUBLIC)) { + warning("The extension entrypoint '" + type.getQualifiedName() + "' is not public. " + + "ExtensionManager can still instantiate it because it calls " + + "setAccessible(true), but making it public is strongly recommended.", type); + } + return valid; + } + + ExecutableElement noArg = null; + for (ExecutableElement constructor : constructors) { + if (constructor.getParameters().isEmpty()) { + noArg = constructor; + break; + } + } + + if (noArg == null) { + error("The extension entrypoint '" + type.getQualifiedName() + + "' has no no-arg constructor. ExtensionManager instantiates the entrypoint via " + + "getDeclaredConstructor().newInstance() and cannot supply any arguments.", type); + return false; + } + + if (!noArg.getModifiers().contains(Modifier.PUBLIC) + || !type.getModifiers().contains(Modifier.PUBLIC)) { + warning("The extension entrypoint '" + type.getQualifiedName() + + "' or its no-arg constructor is not public. ExtensionManager can still " + + "instantiate it because it calls setAccessible(true), but declaring both public " + + "is strongly recommended.", type); + } + return valid; + } + + /** + * Verifies that the entrypoint extends {@code net.minestom.server.extensions.Extension}. + * + *

The check walks the superclass chain and compares fully qualified names, so no Minestom + * class ever has to be referenced from this module. When {@code Extension} is not on the compile + * classpath at all the check is skipped silently: a project may well compile its extension + * against a provided-scope Minestom that is not visible during this particular compilation, and + * failing there would be worse than not checking. + */ + private boolean validateSuperclass(TypeElement type) { + if (processingEnv.getElementUtils().getTypeElement(EXTENSION_CLASS) == null) { + // Extension is not on the classpath - nothing to check against. + return true; + } + + TypeMirror current = type.getSuperclass(); + while (current.getKind() == TypeKind.DECLARED) { + final Element element = ((DeclaredType) current).asElement(); + if (!(element instanceof TypeElement superType)) { + break; + } + if (superType.getQualifiedName().contentEquals(EXTENSION_CLASS)) { + return true; + } + current = superType.getSuperclass(); + } + + error("The extension entrypoint '" + type.getQualifiedName() + "' does not extend " + + EXTENSION_CLASS + ". ExtensionManager casts the entrypoint to that type and refuses " + + "to load the extension otherwise.", type); + return false; + } + + /** Resolves the extension name from the option or the annotation, or {@code null} if invalid. */ + private String resolveName(ExtensionInfo info, TypeElement type) { + final String override = processingEnv.getOptions().get(OPTION_NAME); + final boolean overridden = override != null && !override.isBlank(); + final String name = overridden ? override.trim() : info.name(); + + if (name.isBlank()) { + error("The extension name is empty. Set name() on @ExtensionInfo or pass -A" + + OPTION_NAME + "=.", type); + return null; + } + + if (!NAME_PATTERN.matcher(name).matches()) { + error("The extension name '" + name + "' " + (overridden ? "(from -A" + OPTION_NAME + ") " : "") + + "is invalid: it must match " + EXTENSION_NAME_REGEX + + ", i.e. start with a letter, contain only letters, digits and underscores, and be " + + "at least two characters long. The runtime would reject it with INVALID_NAME.", + type); + return null; + } + return name; + } + + /** + * Resolves the version from the option or the annotation. Returns {@code null} when neither is + * set, in which case the field is omitted and the runtime defaults it to {@code "Unspecified"}. + */ + private String resolveVersion(ExtensionInfo info, TypeElement type) { + final String override = processingEnv.getOptions().get(OPTION_VERSION); + if (override != null && !override.isBlank()) { + return override.trim(); + } + if (!info.version().isBlank()) { + return info.version(); + } + + warning("The extension '" + type.getQualifiedName() + "' does not declare a version. Set " + + "version() on @ExtensionInfo or pass -A" + OPTION_VERSION + "=. The " + + "version field is omitted and the runtime will report 'Unspecified'.", type); + return null; + } + + /** Deduplicates the declared extension dependencies, warning about every duplicate. */ + private List resolveDependencies(ExtensionInfo info, TypeElement type) { + final Set unique = new LinkedHashSet<>(); + for (String dependency : info.dependencies()) { + if (!unique.add(dependency)) { + warning("The extension dependency '" + dependency + "' is declared more than once on '" + + type.getQualifiedName() + "'. The duplicate is ignored.", type); + } + } + return List.copyOf(unique); + } + + /** + * Collects the Maven coordinates, erroring on blank ones, warning about malformed ones and + * dropping duplicates. + * + *

Duplicates are worth a diagnostic because {@code ExtensionManager.loadDependencies} + * resolves each coordinate it is given, so a repeated entry costs a second resolution and adds + * the same URL to the {@code ExtensionClassLoader} twice. + */ + private boolean collectArtifacts(ExtensionInfo info, TypeElement type, List target) { + boolean valid = true; + final Set unique = new LinkedHashSet<>(); + for (ExternalDependency dependency : info.externalDependencies()) { + final String coordinate = dependency.value(); + if (coordinate.isBlank()) { + error("An @ExternalDependency of '" + type.getQualifiedName() + "' has an empty " + + "coordinate. Expected group:artifact:version.", type); + valid = false; + continue; + } + if (!COORDINATE_PATTERN.matcher(coordinate).matches()) { + warning("The external dependency '" + coordinate + "' of '" + type.getQualifiedName() + + "' does not look like a Maven coordinate (group:artifact:version). The " + + "runtime resolver will most likely fail to resolve it.", type); + } + if (!unique.add(coordinate)) { + warning("The external dependency '" + coordinate + "' is declared more than once on '" + + type.getQualifiedName() + "'. The duplicate is ignored.", type); + } + } + target.addAll(unique); + return valid; + } + + /** + * Validates that every repository has a usable name and an http(s) URL, and removes duplicates + * from the given list in place. + * + *

The same name declared with two different URLs is an error rather than a warning: which of + * the two the runtime would end up using is not something the author can have meant either way. + */ + private boolean validateRepositories(List repositories, TypeElement type) { + boolean valid = true; + final Map byName = new LinkedHashMap<>(); + for (Repository repository : repositories) { + if (repository.name().isBlank()) { + error("A @Repository of '" + type.getQualifiedName() + "' has an empty name. " + + "ExtensionManager rejects repositories without a name at runtime.", type); + valid = false; + } + + final String url = repository.url(); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + error("The repository URL '" + url + "' of '" + type.getQualifiedName() + + "' is invalid: repository URLs must start with 'http://' or 'https://'.", + type); + valid = false; + } + + final Repository existing = byName.putIfAbsent(repository.name(), repository); + if (existing == null) { + continue; + } + if (existing.url().equals(url)) { + warning("The repository '" + repository.name() + "' is declared more than once on '" + + type.getQualifiedName() + "'. The duplicate is ignored.", type); + } else { + error("The repository name '" + repository.name() + "' is declared twice on '" + + type.getQualifiedName() + "' with different URLs ('" + existing.url() + + "' and '" + url + "'). Repository names must be unique.", type); + valid = false; + } + } + + repositories.clear(); + repositories.addAll(byName.values()); + return valid; + } + + /** + * Renders the descriptor. Empty collections are omitted entirely instead of being written as + * empty arrays - {@code DiscoveredExtension} defaults every missing field, so the smaller file is + * equivalent and easier to read inside a jar. + */ + private String render(String name, + String entrypointClass, + String version, + List authors, + List dependencies, + List repositories, + List artifacts) { + // Rendering goes through ExtensionDescriptor so the build plugins, which rewrite this same + // file to add the dependencies declared in the build, cannot drift from this format. + return new ExtensionDescriptor() + .name(name) + .entrypoint(entrypointClass) + .version(version) + .authors(authors) + .dependencies(dependencies) + .repositories(repositories.stream() + .map(r -> new ExtensionDescriptor.RepositoryEntry(r.name(), r.url())) + .toList()) + .artifacts(artifacts) + .toJson(); + } + + /** Writes the descriptor to the root of the class output. */ + private void write(String json, TypeElement type) { + final Filer filer = processingEnv.getFiler(); + try { + final FileObject resource = + filer.createResource(StandardLocation.CLASS_OUTPUT, "", OUTPUT_FILE, type); + try (Writer writer = resource.openWriter()) { + writer.write(json); + } + } catch (IOException e) { + error("Could not write " + OUTPUT_FILE + ": " + e, type); + } + } + + private void error(String message, Element element) { + messager().printMessage(Diagnostic.Kind.ERROR, message, element); + } + + private void warning(String message, Element element) { + messager().printMessage(Diagnostic.Kind.WARNING, message, element); + } + + private Messager messager() { + return processingEnv.getMessager(); + } +} diff --git a/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExternalDependency.java b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExternalDependency.java new file mode 100644 index 0000000..be078fa --- /dev/null +++ b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/ExternalDependency.java @@ -0,0 +1,60 @@ +package net.onelitefeather.minestom.extensions.processor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A single Maven coordinate that is downloaded at server startup and added to the class loader of + * the extension. + * + *

This annotation is only ever used inside {@link ExtensionInfo#externalDependencies()}. It is + * declared as a top-level annotation type rather than as a nested one so that the usage site stays + * flat and readable - see the class documentation of {@link ExtensionInfo} for the reasoning. + * Applying it directly to a class does nothing; the processor only looks at {@link ExtensionInfo}. + * + *

The value is passed to the runtime dependency resolver verbatim, so it has to be a plain Maven + * coordinate in {@code group:artifact:version} form (an optional fourth {@code :classifier} segment + * is accepted). Anything that does not look like a coordinate produces a warning; a blank value is + * an error. + * + *

Example

+ *
{@code
+ * @ExtensionInfo(
+ *         name = "MyExtension",
+ *         externalDependencies = {
+ *                 @ExternalDependency("com.google.guava:guava:33.4.0-jre"),
+ *                 @ExternalDependency("org.apache.commons:commons-lang3:3.17.0")
+ *         }
+ * )
+ * public final class MyExtension extends Extension { }
+ * }
+ * + * which contributes + * + *
{@code
+ * "externalDependencies": {
+ *   "artifacts": [
+ *     "com.google.guava:guava:33.4.0-jre",
+ *     "org.apache.commons:commons-lang3:3.17.0"
+ *   ]
+ * }
+ * }
+ * + * @see ExtensionInfo#externalDependencies() + * @since 2.0.0 + */ +@Documented +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +public @interface ExternalDependency { + + /** + * The Maven coordinate, for example {@code "com.google.guava:guava:33.4.0-jre"}. + * + * @return the Maven coordinate to resolve + */ + String value(); +} diff --git a/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/JsonReader.java b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/JsonReader.java new file mode 100644 index 0000000..7f70251 --- /dev/null +++ b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/JsonReader.java @@ -0,0 +1,167 @@ +package net.onelitefeather.minestom.extensions.processor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A minimal, dependency-free JSON parser covering exactly what an {@code extension.json} can + * contain: objects, arrays and strings. + * + *

The counterpart to {@link JsonWriter}. It exists so the build plugins can re-read a descriptor + * without dragging a JSON library into their own dependencies, and so that reading and writing stay + * in one place. + * + *

Numbers, booleans and {@code null} are rejected rather than silently coerced - the descriptor + * format has no place for them, and quietly accepting them would let a typo through to the server. + */ +final class JsonReader { + + private final String input; + private int pos; + + private JsonReader(String input) { + this.input = input; + } + + /** + * Parses a JSON object. + * + * @param json the document + * @return the parsed object; values are {@code String}, {@code List} or {@code Map} + * @throws IllegalArgumentException if the document is not a well-formed JSON object of the + * supported subset + */ + static Map parseObject(String json) { + final JsonReader reader = new JsonReader(json); + reader.skipWhitespace(); + final Map result = reader.readObject(); + reader.skipWhitespace(); + if (reader.pos != reader.input.length()) { + throw reader.error("trailing content after the top-level object"); + } + return result; + } + + private Map readObject() { + expect('{'); + final Map object = new LinkedHashMap<>(); + skipWhitespace(); + if (peek() == '}') { + pos++; + return object; + } + while (true) { + skipWhitespace(); + final String key = readString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + object.put(key, readValue()); + skipWhitespace(); + final char c = next(); + if (c == '}') { + return object; + } + if (c != ',') { + throw error("expected ',' or '}' but found '" + c + "'"); + } + } + } + + private List readArray() { + expect('['); + final List array = new ArrayList<>(); + skipWhitespace(); + if (peek() == ']') { + pos++; + return array; + } + while (true) { + skipWhitespace(); + array.add(readValue()); + skipWhitespace(); + final char c = next(); + if (c == ']') { + return array; + } + if (c != ',') { + throw error("expected ',' or ']' but found '" + c + "'"); + } + } + } + + private Object readValue() { + return switch (peek()) { + case '{' -> readObject(); + case '[' -> readArray(); + case '"' -> readString(); + default -> throw error("only objects, arrays and strings are supported in extension.json"); + }; + } + + private String readString() { + expect('"'); + final StringBuilder value = new StringBuilder(); + while (true) { + final char c = next(); + if (c == '"') { + return value.toString(); + } + if (c != '\\') { + value.append(c); + continue; + } + final char escape = next(); + switch (escape) { + case '"' -> value.append('"'); + case '\\' -> value.append('\\'); + case '/' -> value.append('/'); + case 'b' -> value.append('\b'); + case 'f' -> value.append('\f'); + case 'n' -> value.append('\n'); + case 'r' -> value.append('\r'); + case 't' -> value.append('\t'); + case 'u' -> { + if (pos + 4 > input.length()) { + throw error("truncated unicode escape"); + } + value.append((char) Integer.parseInt(input.substring(pos, pos + 4), 16)); + pos += 4; + } + default -> throw error("unsupported escape '\\" + escape + "'"); + } + } + } + + private void skipWhitespace() { + while (pos < input.length() && Character.isWhitespace(input.charAt(pos))) { + pos++; + } + } + + private char peek() { + if (pos >= input.length()) { + throw error("unexpected end of document"); + } + return input.charAt(pos); + } + + private char next() { + final char c = peek(); + pos++; + return c; + } + + private void expect(char expected) { + final char c = next(); + if (c != expected) { + throw error("expected '" + expected + "' but found '" + c + "'"); + } + } + + private IllegalArgumentException error(String message) { + return new IllegalArgumentException("Invalid extension descriptor at offset " + pos + ": " + message); + } +} diff --git a/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/JsonWriter.java b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/JsonWriter.java new file mode 100644 index 0000000..fc2345a --- /dev/null +++ b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/JsonWriter.java @@ -0,0 +1,211 @@ +package net.onelitefeather.minestom.extensions.processor; + +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * A minimal, dependency-free JSON writer that produces pretty printed output indented with two + * spaces. + * + *

The processor module is deliberately free of runtime dependencies (see the module build file), + * so it cannot use Gson - which is what reads the generated file again on the server side. This + * class covers exactly the subset of JSON that {@code extension.json} needs: objects, arrays and + * string values. There is no support for numbers, booleans or {@code null} because the target + * format has no place for them. + * + *

Instances are not thread-safe and are meant to be used once and thrown away. + */ +final class JsonWriter { + + /** One level of indentation. */ + private static final String INDENT_UNIT = " "; + + private final StringBuilder out = new StringBuilder(); + + /** + * One entry per open object/array. {@code Boolean.TRUE} means the scope is still empty, so the + * next member must not be preceded by a comma. The size of the stack doubles as the current + * indentation depth. + */ + private final Deque emptyScopes = new ArrayDeque<>(); + + /** {@code true} directly after {@link #name(String)}, where the value follows on the same line. */ + private boolean afterName; + + /** + * Opens a JSON object. + * + * @return this writer + */ + JsonWriter beginObject() { + open('{'); + return this; + } + + /** + * Closes the most recently opened JSON object. + * + * @return this writer + */ + JsonWriter endObject() { + close('}'); + return this; + } + + /** + * Opens a JSON array. + * + * @return this writer + */ + JsonWriter beginArray() { + open('['); + return this; + } + + /** + * Closes the most recently opened JSON array. + * + * @return this writer + */ + JsonWriter endArray() { + close(']'); + return this; + } + + /** + * Writes a member name inside the current object. The next written value belongs to it. + * + * @param name the member name + * @return this writer + */ + JsonWriter name(String name) { + prepareMember(); + writeString(name); + out.append(": "); + afterName = true; + return this; + } + + /** + * Writes a string value, escaped according to the JSON specification. + * + * @param value the value + * @return this writer + */ + JsonWriter value(String value) { + prepareMember(); + writeString(value); + return this; + } + + /** + * Writes a member consisting of a name and an array of string values. + * + * @param name the member name + * @param values the array elements + * @return this writer + */ + JsonWriter arrayMember(String name, Iterable values) { + name(name); + beginArray(); + for (String value : values) { + value(value); + } + return endArray(); + } + + /** + * Writes a member consisting of a name and a string value. + * + * @param name the member name + * @param value the value + * @return this writer + */ + JsonWriter stringMember(String name, String value) { + return name(name).value(value); + } + + /** + * Renders everything written so far, terminated by a single trailing newline so the file is well + * behaved in a text editor. + * + * @return the JSON document + */ + @Override + public String toString() { + return out + "\n"; + } + + private void open(char brace) { + prepareMember(); + out.append(brace); + emptyScopes.push(Boolean.TRUE); + } + + private void close(char brace) { + final boolean empty = emptyScopes.pop(); + if (!empty) { + // A non-empty scope always ends on its own line, indented like its opening member. + out.append('\n').append(INDENT_UNIT.repeat(emptyScopes.size())); + } + out.append(brace); + } + + /** + * Emits whatever has to precede the next member or array element: a comma if the enclosing scope + * already has content, then a line break and the indentation for the current depth. + */ + private void prepareMember() { + if (afterName) { + // The value of a member stays on the same line as its name. + afterName = false; + return; + } + if (emptyScopes.isEmpty()) { + // Root value, nothing precedes it. + return; + } + if (!emptyScopes.pop()) { + out.append(','); + } + emptyScopes.push(Boolean.FALSE); + out.append('\n').append(INDENT_UNIT.repeat(emptyScopes.size())); + } + + /** + * Appends a JSON string literal, escaping backslash, double quote, the short escapes for + * {@code \b \f \n \r \t} and every character outside printable ASCII as {@code \ uXXXX}. + * + *

Escaping non-ASCII is deliberate rather than cosmetic. The descriptor is written through + * the {@link javax.annotation.processing.Filer}, which encodes with javac's {@code -encoding} + * setting, while {@code ExtensionManager} reads it back with the JVM default charset. A + * consumer building with a legacy encoding would otherwise ship an author or dependency name + * that is mojibake - or, for characters the build encoding cannot represent at all, silently + * replaced by {@code ?} before the runtime ever sees it. Pure ASCII output is immune to both + * ends of that mismatch. Characters outside the BMP are escaped as their two surrogate code + * units, which is exactly the pair a JSON parser recombines. + */ + private void writeString(String value) { + out.append('"'); + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + default -> { + if (c < 0x20 || c > 0x7e) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + out.append('"'); + } +} diff --git a/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/Repository.java b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/Repository.java new file mode 100644 index 0000000..78cd866 --- /dev/null +++ b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/Repository.java @@ -0,0 +1,77 @@ +package net.onelitefeather.minestom.extensions.processor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A single Maven repository that {@link ExtensionInfo#externalDependencies()} are resolved against. + * + *

This annotation is only ever used inside {@link ExtensionInfo#repositories()}. It is declared + * as a top-level annotation type rather than as a nested one so that the usage site stays flat and + * readable - see the class documentation of {@link ExtensionInfo} for the reasoning. Applying it + * directly to a class does nothing; the processor only looks at {@link ExtensionInfo}. + * + *

Both elements are mandatory and must be non-blank: {@code ExtensionManager} rejects a + * repository with a missing {@code name} or {@code url} at runtime, so the processor rejects it at + * compile time. The URL has to start with {@code http://} or {@code https://}. + * + *

Example

+ *
{@code
+ * @ExtensionInfo(
+ *         name = "MyExtension",
+ *         repositories = {
+ *                 @Repository(name = "onelitefeather", url = "https://repo.onelitefeather.dev/onelitefeather")
+ *         },
+ *         externalDependencies = {
+ *                 @ExternalDependency("net.onelitefeather:some-library:1.2.3")
+ *         }
+ * )
+ * public final class MyExtension extends Extension { }
+ * }
+ * + * which contributes + * + *
{@code
+ * "externalDependencies": {
+ *   "repositories": [
+ *     {
+ *       "name": "onelitefeather",
+ *       "url": "https://repo.onelitefeather.dev/onelitefeather"
+ *     }
+ *   ],
+ *   "artifacts": [
+ *     "net.onelitefeather:some-library:1.2.3"
+ *   ]
+ * }
+ * }
+ * + * @see ExtensionInfo#repositories() + * @since 2.0.0 + */ +@Documented +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +public @interface Repository { + + /** + * The identifier of the repository, for example {@code "central"}. + * + *

Must not be blank. It is only used to identify the repository in logs and in the resolver. + * + * @return the repository name + */ + String name(); + + /** + * The base URL of the repository, for example {@code "https://repo1.maven.org/maven2/"}. + * + *

Must start with {@code http://} or {@code https://}; anything else fails the build, since + * the runtime dependency resolver can only speak HTTP. + * + * @return the repository URL + */ + String url(); +} diff --git a/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/package-info.java b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/package-info.java new file mode 100644 index 0000000..f57b0d2 --- /dev/null +++ b/minestom-extensions-processor/src/main/java/net/onelitefeather/minestom/extensions/processor/package-info.java @@ -0,0 +1,16 @@ +/** + * Compile-time generation of the {@code extension.json} descriptor for Minestom extensions. + * + *

Annotate the entrypoint class with + * {@link net.onelitefeather.minestom.extensions.processor.ExtensionInfo} and + * {@link net.onelitefeather.minestom.extensions.processor.ExtensionInfoProcessor} writes the + * descriptor into the root of the compiled output, where {@code ExtensionManager} expects it inside + * the finished jar. + * + *

This package has no runtime dependencies at all - not even on + * {@code net.onelitefeather:minestom-extensions} - so that adding it to a project's annotation + * processor path never drags Minestom onto that path. + * + * @since 2.0.0 + */ +package net.onelitefeather.minestom.extensions.processor; diff --git a/minestom-extensions-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/minestom-extensions-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 0000000..6bd7f20 --- /dev/null +++ b/minestom-extensions-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +net.onelitefeather.minestom.extensions.processor.ExtensionInfoProcessor diff --git a/minestom-extensions-processor/src/test/java/net/onelitefeather/minestom/extensions/processor/CompilationHarness.java b/minestom-extensions-processor/src/test/java/net/onelitefeather/minestom/extensions/processor/CompilationHarness.java new file mode 100644 index 0000000..e53dcf7 --- /dev/null +++ b/minestom-extensions-processor/src/test/java/net/onelitefeather/minestom/extensions/processor/CompilationHarness.java @@ -0,0 +1,234 @@ +package net.onelitefeather.minestom.extensions.processor; + +import javax.tools.Diagnostic; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Runs {@code javac} in-process over a set of in-memory sources with {@link ExtensionInfoProcessor} + * attached, and exposes the diagnostics plus whatever landed in the class output directory. + */ +final class CompilationHarness { + + private CompilationHarness() { + } + + /** + * The classpath handed to the test compilations: exactly the directory (or jar) that contains + * the processor and its annotations. + * + *

Deliberately not {@code java.class.path} - the Gradle test worker does not necessarily put + * the test classpath there, and more importantly this keeps the compilation classpath minimal. + * Notably {@code net.minestom.server.extensions.Extension} is not on it, because this + * module does not depend on {@code minestom-extensions}. Tests that need {@code Extension} + * declare it as an additional source. + */ + private static final String CLASSPATH = codeSourceOf(); + + /** Builder for one compilation run. */ + static final class Builder { + + private final Path workDir; + private final Map sources = new LinkedHashMap<>(); + private final List options = new ArrayList<>(); + + private Builder(Path workDir) { + this.workDir = workDir; + } + + /** + * Adds a source file. + * + * @param fqn fully qualified name of the declared type + * @param content the source code + * @return this builder + */ + Builder source(String fqn, String content) { + sources.put(fqn, content); + return this; + } + + /** + * Adds a {@code -A} compiler option. + * + * @param key option name + * @param value option value + * @return this builder + */ + Builder option(String key, String value) { + options.add("-A" + key + "=" + value); + return this; + } + + /** + * Declares a stand-in for {@code net.minestom.server.extensions.Extension}, compiled together + * with the other sources. This is how a test puts {@code Extension} on the compilation's + * classpath without this module depending on {@code minestom-extensions}. + * + * @return this builder + */ + Builder withExtensionClass() { + return source("net.minestom.server.extensions.Extension", + "package net.minestom.server.extensions;\n" + + "public abstract class Extension {\n" + + " public void initialize() {}\n" + + "}\n"); + } + + /** + * Compiles everything. + * + * @return the result of the compilation + */ + Result compile() { + final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException("No system java compiler available"); + } + + final Path classOutput = workDir.resolve("classes"); + try { + Files.createDirectories(classOutput); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + final javax.tools.DiagnosticCollector diagnostics = + new javax.tools.DiagnosticCollector<>(); + + final List allOptions = new ArrayList<>(List.of( + "-d", classOutput.toString(), + "-classpath", CLASSPATH, + "-proc:full")); + allOptions.addAll(options); + + final List units = sources.entrySet().stream() + .map(entry -> (JavaFileObject) new InMemorySource(entry.getKey(), entry.getValue())) + .toList(); + + final boolean success; + try (StandardJavaFileManager fileManager = + compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + final JavaCompiler.CompilationTask task = + compiler.getTask(null, fileManager, diagnostics, allOptions, null, units); + // Explicit registration keeps the test independent of how the harness classpath is + // assembled; the META-INF/services registration is verified separately. + task.setProcessors(List.of(new ExtensionInfoProcessor())); + success = task.call(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + return new Result(success, diagnostics.getDiagnostics(), classOutput); + } + } + + /** + * Starts a new compilation. + * + * @param workDir a scratch directory, usually a JUnit {@code @TempDir} + * @return a builder + */ + static Builder javac(Path workDir) { + return new Builder(workDir); + } + + /** The outcome of one compilation. */ + record Result(boolean success, + List> diagnostics, + Path classOutput) { + + /** + * @return the raw content of the generated {@code extension.json} + */ + String extensionJson() { + final Path file = classOutput.resolve(ExtensionInfoProcessor.OUTPUT_FILE); + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("extension.json was not generated", e); + } + } + + /** + * @return whether an {@code extension.json} was generated at all + */ + boolean hasExtensionJson() { + return Files.exists(classOutput.resolve(ExtensionInfoProcessor.OUTPUT_FILE)); + } + + /** + * @param kind the diagnostic kind to filter for + * @return all messages of that kind + */ + List messages(Diagnostic.Kind kind) { + return diagnostics.stream() + .filter(diagnostic -> diagnostic.getKind() == kind) + .map(diagnostic -> diagnostic.getMessage(null)) + .toList(); + } + + /** + * @param kind the diagnostic kind to filter for + * @param fragment a substring to look for + * @return the first matching message, if any + */ + Optional firstMessageContaining(Diagnostic.Kind kind, String fragment) { + return messages(kind).stream().filter(message -> message.contains(fragment)).findFirst(); + } + + /** + * @return every diagnostic rendered as text, for assertion failure messages + */ + String describe() { + return diagnostics.stream() + .map(diagnostic -> diagnostic.getKind() + ": " + diagnostic.getMessage(null)) + .collect(Collectors.joining("\n")); + } + } + + /** A source file that lives purely in memory. */ + private static final class InMemorySource extends SimpleJavaFileObject { + + private final String content; + + private InMemorySource(String fqn, String content) { + super(URI.create("string:///" + fqn.replace('.', '/') + Kind.SOURCE.extension), + Kind.SOURCE); + this.content = content; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return content; + } + } + + private static String codeSourceOf() { + try { + return Path.of(ExtensionInfo.class.getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()) + .toString(); + } catch (URISyntaxException e) { + throw new IllegalStateException("Cannot locate the processor classes", e); + } + } +} diff --git a/minestom-extensions-processor/src/test/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfoProcessorTest.java b/minestom-extensions-processor/src/test/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfoProcessorTest.java new file mode 100644 index 0000000..3ca81e4 --- /dev/null +++ b/minestom-extensions-processor/src/test/java/net/onelitefeather/minestom/extensions/processor/ExtensionInfoProcessorTest.java @@ -0,0 +1,612 @@ +package net.onelitefeather.minestom.extensions.processor; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.annotation.processing.Processor; +import javax.tools.Diagnostic; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.ServiceLoader; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end tests: they run the real {@code javac} over generated sources with the real processor + * attached and assert on the generated {@code extension.json}. + */ +class ExtensionInfoProcessorTest { + + private static final String IMPORTS = """ + import net.onelitefeather.minestom.extensions.processor.ExtensionInfo; + import net.onelitefeather.minestom.extensions.processor.ExternalDependency; + import net.onelitefeather.minestom.extensions.processor.Repository; + """; + + @Test + @DisplayName("happy path: every element ends up in extension.json") + void happyPath(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.MyExtension", """ + package com.example; + %s + @ExtensionInfo( + name = "MyExtension", + version = "1.2.3", + authors = {"Alice", "Bob"}, + dependencies = {"OtherExtension"}, + repositories = { + @Repository(name = "central", url = "https://repo1.maven.org/maven2/") + }, + externalDependencies = { + @ExternalDependency("com.google.guava:guava:33.4.0-jre") + } + ) + public class MyExtension { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + + final JsonObject json = parse(result.extensionJson()); + assertAll( + () -> assertEquals("MyExtension", json.get("name").getAsString()), + () -> assertEquals("com.example.MyExtension", json.get("entrypoint").getAsString()), + () -> assertEquals("1.2.3", json.get("version").getAsString()), + () -> assertEquals(List.of("Alice", "Bob"), strings(json.getAsJsonArray("authors"))), + () -> assertEquals(List.of("OtherExtension"), + strings(json.getAsJsonArray("dependencies"))), + () -> assertFalse(json.has("meta"), "meta must never be generated")); + + final JsonObject external = json.getAsJsonObject("externalDependencies"); + assertNotNull(external, "externalDependencies missing"); + assertEquals(List.of("com.google.guava:guava:33.4.0-jre"), + strings(external.getAsJsonArray("artifacts"))); + + final JsonArray repositories = external.getAsJsonArray("repositories"); + assertEquals(1, repositories.size()); + final JsonObject repository = repositories.get(0).getAsJsonObject(); + assertAll( + () -> assertEquals("central", repository.get("name").getAsString()), + () -> assertEquals("https://repo1.maven.org/maven2/", + repository.get("url").getAsString())); + } + + @Test + @DisplayName("minimal extension: empty collections are omitted entirely") + void minimalExtension(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Tiny", """ + package com.example; + %s + @ExtensionInfo(name = "Tiny", version = "1.0.0") + public class Tiny { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + + final JsonObject json = parse(result.extensionJson()); + assertAll( + () -> assertEquals("Tiny", json.get("name").getAsString()), + () -> assertEquals("com.example.Tiny", json.get("entrypoint").getAsString()), + () -> assertEquals("1.0.0", json.get("version").getAsString()), + () -> assertFalse(json.has("authors")), + () -> assertFalse(json.has("dependencies")), + () -> assertFalse(json.has("externalDependencies")), + () -> assertFalse(json.has("meta"))); + } + + @Test + @DisplayName("entrypoint is the binary name, so nested classes use '$'") + void entrypointIsBinaryName(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Outer", """ + package com.example; + %s + public class Outer { + @ExtensionInfo(name = "Nested", version = "1.0.0") + public static class Inner { + } + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + assertEquals("com.example.Outer$Inner", + parse(result.extensionJson()).get("entrypoint").getAsString()); + } + + @Test + @DisplayName("-Aminestom.extension.version overrides the annotation") + void versionOptionOverridesAnnotation(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Versioned", """ + package com.example; + %s + @ExtensionInfo(name = "Versioned", version = "1.0.0") + public class Versioned { + } + """.formatted(IMPORTS)) + .option(ExtensionInfoProcessor.OPTION_VERSION, "9.9.9-SNAPSHOT") + .compile(); + + assertTrue(result.success(), result::describe); + assertEquals("9.9.9-SNAPSHOT", parse(result.extensionJson()).get("version").getAsString()); + } + + @Test + @DisplayName("-Aminestom.extension.name overrides the annotation") + void nameOptionOverridesAnnotation(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Named", """ + package com.example; + %s + @ExtensionInfo(name = "FromAnnotation", version = "1.0.0") + public class Named { + } + """.formatted(IMPORTS)) + .option(ExtensionInfoProcessor.OPTION_NAME, "FromOption") + .compile(); + + assertTrue(result.success(), result::describe); + assertEquals("FromOption", parse(result.extensionJson()).get("name").getAsString()); + } + + @Test + @DisplayName("missing version is a warning and the field is omitted") + void missingVersionIsAWarning(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.NoVersion", """ + package com.example; + %s + @ExtensionInfo(name = "NoVersion") + public class NoVersion { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.WARNING, "does not declare a version") + .isPresent(), result::describe); + assertFalse(parse(result.extensionJson()).has("version")); + } + + @Test + @DisplayName("duplicate dependencies warn and are collapsed") + void duplicateDependenciesWarn(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Dupes", """ + package com.example; + %s + @ExtensionInfo( + name = "Dupes", + version = "1.0.0", + dependencies = {"A_Extension", "B_Extension", "A_Extension"} + ) + public class Dupes { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.WARNING, "declared more than once") + .isPresent(), result::describe); + assertEquals(List.of("A_Extension", "B_Extension"), + strings(parse(result.extensionJson()).getAsJsonArray("dependencies"))); + } + + @Test + @DisplayName("an invalid name fails the compilation") + void invalidNameFailsCompilation(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Bad", """ + package com.example; + %s + @ExtensionInfo(name = "1nvalid Name!", version = "1.0.0") + public class Bad { + } + """.formatted(IMPORTS)) + .compile(); + + assertFalse(result.success(), "compilation should have failed"); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.ERROR, "is invalid: it must match") + .isPresent(), result::describe); + assertFalse(result.hasExtensionJson(), "no descriptor may be written on error"); + } + + @Test + @DisplayName("two annotated classes fail the compilation") + void twoAnnotatedClassesFailCompilation(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.First", """ + package com.example; + %s + @ExtensionInfo(name = "First", version = "1.0.0") + public class First { + } + """.formatted(IMPORTS)) + .source("com.example.Second", """ + package com.example; + %s + @ExtensionInfo(name = "Second", version = "1.0.0") + public class Second { + } + """.formatted(IMPORTS)) + .compile(); + + assertFalse(result.success(), "compilation should have failed"); + assertTrue(result.firstMessageContaining( + Diagnostic.Kind.ERROR, "more than one class annotated with @ExtensionInfo") + .isPresent(), result::describe); + assertFalse(result.hasExtensionJson(), "no descriptor may be written on error"); + } + + @Test + @DisplayName("a repository URL without http(s) scheme fails the compilation") + void invalidRepositoryUrlFailsCompilation(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.BadRepo", """ + package com.example; + %s + @ExtensionInfo( + name = "BadRepo", + version = "1.0.0", + repositories = {@Repository(name = "local", url = "file:///tmp/repo")} + ) + public class BadRepo { + } + """.formatted(IMPORTS)) + .compile(); + + assertFalse(result.success(), "compilation should have failed"); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.ERROR, "must start with 'http://'") + .isPresent(), result::describe); + assertFalse(result.hasExtensionJson(), "no descriptor may be written on error"); + } + + @Test + @DisplayName("strings are escaped according to the JSON spec") + void stringsAreEscaped(@TempDir Path workDir) { + // The generated source contains a string with a quote, a backslash, a tab, a + // newline and the control character 0x01. + final String author = "He said \\\"hi\\\" \\\\ and\\ttab\\nnewline\\u0001"; + final var result = CompilationHarness.javac(workDir) + .source("com.example.Escapes", """ + package com.example; + %s + @ExtensionInfo(name = "Escapes", version = "1.0.0", authors = {"%s"}) + public class Escapes { + } + """.formatted(IMPORTS, author)) + .compile(); + + assertTrue(result.success(), result::describe); + + final String raw = result.extensionJson(); + assertAll( + () -> assertTrue(raw.contains("\\\""), "quote must be escaped: " + raw), + () -> assertTrue(raw.contains("\\\\"), "backslash must be escaped: " + raw), + () -> assertTrue(raw.contains("\\t"), "tab must be escaped: " + raw), + () -> assertTrue(raw.contains("\\n"), "newline must be escaped: " + raw), + () -> assertTrue(raw.contains("\\u0001"), "control char must be escaped: " + raw)); + + // The decisive check: a JSON parser has to round-trip the exact original string. + assertEquals("He said \"hi\" \\ and\ttab\nnewline" + (char) 0x01, + strings(parse(raw).getAsJsonArray("authors")).get(0)); + } + + @Test + @DisplayName("the superclass check is skipped when Extension is not on the classpath") + void superclassCheckSkippedWithoutExtensionOnClasspath(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Standalone", """ + package com.example; + %s + @ExtensionInfo(name = "Standalone", version = "1.0.0") + public class Standalone { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.ERROR, "does not extend").isEmpty(), + result::describe); + assertTrue(result.hasExtensionJson()); + } + + @Test + @DisplayName("extending Extension transitively is accepted") + void transitiveExtensionSubclassIsAccepted(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .withExtensionClass() + .source("com.example.Base", """ + package com.example; + public abstract class Base extends net.minestom.server.extensions.Extension { + } + """) + .source("com.example.Real", """ + package com.example; + %s + @ExtensionInfo(name = "Real", version = "1.0.0") + public class Real extends Base { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + assertEquals("com.example.Real", parse(result.extensionJson()).get("entrypoint").getAsString()); + } + + @Test + @DisplayName("not extending Extension fails when Extension is on the classpath") + void notExtendingExtensionFails(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .withExtensionClass() + .source("com.example.NotAnExtension", """ + package com.example; + %s + @ExtensionInfo(name = "NotAnExtension", version = "1.0.0") + public class NotAnExtension { + } + """.formatted(IMPORTS)) + .compile(); + + assertFalse(result.success(), "compilation should have failed"); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.ERROR, + "does not extend net.minestom.server.extensions.Extension").isPresent(), + result::describe); + assertFalse(result.hasExtensionJson()); + } + + @Test + @DisplayName("an abstract entrypoint fails the compilation") + void abstractEntrypointFails(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.AbstractExtension", """ + package com.example; + %s + @ExtensionInfo(name = "AbstractExtension", version = "1.0.0") + public abstract class AbstractExtension { + } + """.formatted(IMPORTS)) + .compile(); + + assertFalse(result.success(), "compilation should have failed"); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.ERROR, "must not be abstract") + .isPresent(), result::describe); + } + + @Test + @DisplayName("a non-static inner class fails the compilation") + void innerClassEntrypointFails(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Holder", """ + package com.example; + %s + public class Holder { + @ExtensionInfo(name = "InnerExtension", version = "1.0.0") + public class Inner { + } + } + """.formatted(IMPORTS)) + .compile(); + + assertFalse(result.success(), "compilation should have failed"); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.ERROR, "is an inner class") + .isPresent(), result::describe); + } + + @Test + @DisplayName("a missing no-arg constructor fails the compilation") + void missingNoArgConstructorFails(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.NeedsArgs", """ + package com.example; + %s + @ExtensionInfo(name = "NeedsArgs", version = "1.0.0") + public class NeedsArgs { + public NeedsArgs(String argument) { + } + } + """.formatted(IMPORTS)) + .compile(); + + assertFalse(result.success(), "compilation should have failed"); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.ERROR, "has no no-arg constructor") + .isPresent(), result::describe); + } + + @Test + @DisplayName("non-ASCII characters are escaped so the file survives any build encoding") + void nonAsciiIsEscaped(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Umlauts", """ + package com.example; + %s + @ExtensionInfo( + name = "Umlauts", + version = "1.0.0", + authors = {"J\\u00f6rg M\\u00fcller", "\\u4e2d\\u6587", "\\ud83d\\ude00"} + ) + public class Umlauts { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + + final String raw = result.extensionJson(); + // The whole point: the bytes on disk are pure ASCII, so neither javac's -encoding on the + // way out nor the JVM default charset on the way in can corrupt them. + for (int i = 0; i < raw.length(); i++) { + final char c = raw.charAt(i); + assertTrue(c <= 0x7e, () -> "non-ASCII character in output: " + raw); + } + + // ...and a parser still recovers the exact original strings, surrogate pair included. + assertEquals(List.of("Jörg Müller", "中文", "😀"), + strings(parse(raw).getAsJsonArray("authors"))); + } + + @Test + @DisplayName("duplicate external dependencies and repositories warn and are collapsed") + void duplicateExternalDependenciesWarn(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.DupExternals", """ + package com.example; + %s + @ExtensionInfo( + name = "DupExternals", + version = "1.0.0", + repositories = { + @Repository(name = "central", url = "https://repo1.maven.org/maven2/"), + @Repository(name = "central", url = "https://repo1.maven.org/maven2/") + }, + externalDependencies = { + @ExternalDependency("com.google.guava:guava:33.4.0-jre"), + @ExternalDependency("com.google.guava:guava:33.4.0-jre") + } + ) + public class DupExternals { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + + final JsonObject external = parse(result.extensionJson()) + .getAsJsonObject("externalDependencies"); + assertAll( + () -> assertEquals(List.of("com.google.guava:guava:33.4.0-jre"), + strings(external.getAsJsonArray("artifacts"))), + () -> assertEquals(1, external.getAsJsonArray("repositories").size(), + "the duplicate repository must be collapsed"), + () -> assertTrue(result.firstMessageContaining( + Diagnostic.Kind.WARNING, "external dependency 'com.google.guava:guava:33.4.0-jre' is declared more than once") + .isPresent(), result::describe), + () -> assertTrue(result.firstMessageContaining( + Diagnostic.Kind.WARNING, "repository 'central' is declared more than once") + .isPresent(), result::describe)); + } + + @Test + @DisplayName("the same repository name with two different URLs fails the compilation") + void conflictingRepositoryUrlFailsCompilation(@TempDir Path workDir) { + final var result = CompilationHarness.javac(workDir) + .source("com.example.Conflict", """ + package com.example; + %s + @ExtensionInfo( + name = "Conflict", + version = "1.0.0", + repositories = { + @Repository(name = "olf", url = "https://repo.onelitefeather.dev/releases"), + @Repository(name = "olf", url = "https://repo.onelitefeather.dev/snapshots") + } + ) + public class Conflict { + } + """.formatted(IMPORTS)) + .compile(); + + assertFalse(result.success(), "conflicting repository URLs must fail the build"); + assertTrue(result.firstMessageContaining(Diagnostic.Kind.ERROR, "with different URLs") + .isPresent(), result::describe); + assertFalse(result.hasExtensionJson(), "no descriptor may be written for a failed build"); + } + + @Test + @DisplayName("the generated descriptor matches the contract shared with minestom-extensions") + void generatedDescriptorMatchesSharedContract(@TempDir Path workDir) throws IOException { + // The @ExtensionInfo that produced extension-descriptor-contract.json. Keep the two in sync: + // minestom-extensions deserializes that same file into the real DiscoveredExtension, so this + // test is the half that proves the processor is what actually writes it. + final var result = CompilationHarness.javac(workDir) + .source("com.example.ContractExtension", """ + package com.example; + %s + @ExtensionInfo( + name = "ContractExtension", + version = "4.2.0", + authors = {"TheMeinerLP", "J\\u00f6rg M\\u00fcller"}, + dependencies = {"FirstDependency", "SecondDependency"}, + repositories = { + @Repository(name = "central", url = "https://repo1.maven.org/maven2/"), + @Repository(name = "onelitefeather", url = "https://repo.onelitefeather.dev/releases") + }, + externalDependencies = { + @ExternalDependency("com.google.guava:guava:33.4.0-jre"), + @ExternalDependency("org.apache.commons:commons-lang3:3.17.0") + } + ) + public class ContractExtension { + } + """.formatted(IMPORTS)) + .compile(); + + assertTrue(result.success(), result::describe); + + final String expected; + try (InputStream in = getClass() + .getResourceAsStream("/extension-descriptor-contract.json")) { + assertNotNull(in, "missing shared contract resource - is the descriptorContract " + + "copy task wired into the test resources?"); + expected = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + + // Line endings are normalised rather than compared. .gitattributes checks the contract out + // as LF everywhere, but a Windows working copy predating it — or an editor that rewrites the + // file on save — would otherwise fail this on CRLF alone. What the contract is about is the + // field names, their order, the indentation and the escaping. + assertEquals(normaliseLineEndings(expected), normaliseLineEndings(result.extensionJson()), + "the processor no longer reproduces the descriptor contract that " + + "minestom-extensions asserts against"); + } + + private static String normaliseLineEndings(String value) { + return value.replace("\r\n", "\n").strip(); + } + + @Test + @DisplayName("the processor is registered through META-INF/services") + void processorIsRegisteredAsAService() throws IOException { + final ClassLoader loader = ExtensionInfoProcessor.class.getClassLoader(); + try (InputStream in = loader.getResourceAsStream( + "META-INF/services/javax.annotation.processing.Processor")) { + assertNotNull(in, "service registration file missing"); + assertEquals(ExtensionInfoProcessor.class.getName(), + new String(in.readAllBytes(), StandardCharsets.UTF_8).strip()); + } + + final List discovered = new ArrayList<>(); + ServiceLoader.load(Processor.class, loader) + .forEach(processor -> discovered.add(processor.getClass().getName())); + assertTrue(discovered.contains(ExtensionInfoProcessor.class.getName()), + "ServiceLoader did not discover the processor, found: " + discovered); + } + + private static JsonObject parse(String json) { + return JsonParser.parseString(json).getAsJsonObject(); + } + + private static List strings(JsonArray array) { + final List values = new ArrayList<>(array.size()); + array.forEach(element -> values.add(element.getAsString())); + return values; + } +} diff --git a/minestom-extensions/build.gradle.kts b/minestom-extensions/build.gradle.kts new file mode 100644 index 0000000..b573234 --- /dev/null +++ b/minestom-extensions/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + `java-library` +} + +description = "Extensions for minestom, added externally as a library" + +dependencies { + implementation(platform(libs.myclium.bom)) + compileOnly(libs.minestom) + // Runtime resolution of an extension's externalDependencies. maven-resolver-provider supplies + // the RepositorySystem plus the POM-aware descriptor reader; the connector and transports are + // what actually fetch artifacts, and Aether does nothing without them being registered. + implementation(libs.maven.resolver.provider) + implementation(libs.maven.resolver.connector.basic) + implementation(libs.maven.resolver.transport.http) + implementation(libs.maven.resolver.transport.file) + implementation(libs.slf4j2) + + testImplementation(platform(libs.myclium.bom)) + testImplementation(libs.minestom) + testImplementation(libs.junit.api) + testImplementation(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.engine) + testImplementation(libs.logback.classic) +} + +tasks { + test { + useJUnitPlatform() + jvmArgs("-Dminestom.inside-test=true") + } +} diff --git a/src/main/java/net/hollowcube/minestom/extensions/ExtensionBootstrap.java b/minestom-extensions/src/main/java/net/hollowcube/minestom/extensions/ExtensionBootstrap.java similarity index 100% rename from src/main/java/net/hollowcube/minestom/extensions/ExtensionBootstrap.java rename to minestom-extensions/src/main/java/net/hollowcube/minestom/extensions/ExtensionBootstrap.java diff --git a/src/main/java/net/minestom/server/extensions/DiscoveredExtension.java b/minestom-extensions/src/main/java/net/minestom/server/extensions/DiscoveredExtension.java similarity index 100% rename from src/main/java/net/minestom/server/extensions/DiscoveredExtension.java rename to minestom-extensions/src/main/java/net/minestom/server/extensions/DiscoveredExtension.java diff --git a/src/main/java/net/minestom/server/extensions/Extension.java b/minestom-extensions/src/main/java/net/minestom/server/extensions/Extension.java similarity index 100% rename from src/main/java/net/minestom/server/extensions/Extension.java rename to minestom-extensions/src/main/java/net/minestom/server/extensions/Extension.java diff --git a/src/main/java/net/minestom/server/extensions/ExtensionClassLoader.java b/minestom-extensions/src/main/java/net/minestom/server/extensions/ExtensionClassLoader.java similarity index 100% rename from src/main/java/net/minestom/server/extensions/ExtensionClassLoader.java rename to minestom-extensions/src/main/java/net/minestom/server/extensions/ExtensionClassLoader.java diff --git a/src/main/java/net/minestom/server/extensions/ExtensionManager.java b/minestom-extensions/src/main/java/net/minestom/server/extensions/ExtensionManager.java similarity index 93% rename from src/main/java/net/minestom/server/extensions/ExtensionManager.java rename to minestom-extensions/src/main/java/net/minestom/server/extensions/ExtensionManager.java index d664544..f7f587c 100644 --- a/src/main/java/net/minestom/server/extensions/ExtensionManager.java +++ b/minestom-extensions/src/main/java/net/minestom/server/extensions/ExtensionManager.java @@ -1,9 +1,6 @@ package net.minestom.server.extensions; import com.google.gson.Gson; -import net.minestom.dependencies.DependencyGetter; -import net.minestom.dependencies.ResolvedDependency; -import net.minestom.dependencies.maven.MavenRepository; import net.minestom.server.ServerProcess; import net.minestom.server.utils.validate.Check; import org.jetbrains.annotations.ApiStatus; @@ -548,28 +545,18 @@ private void loadDependencies(@NotNull List extensions) { for (DiscoveredExtension discoveredExtension : extensions) { try { - DependencyGetter getter = new DependencyGetter(); DiscoveredExtension.ExternalDependencies externalDependencies = discoveredExtension.getExternalDependencies(); - List repoList = new LinkedList<>(); - for (var repository : externalDependencies.repositories) { - - if (repository.name == null || repository.name.isEmpty()) { - throw new IllegalStateException("Missing 'name' element in repository object."); - } - - if (repository.url == null || repository.url.isEmpty()) { - throw new IllegalStateException("Missing 'url' element in repository object."); + var repoList = MavenDependencyResolver.toRemoteRepositories(externalDependencies.repositories); + + // Only pay for the resolver when the extension actually asks for external artifacts. + if (externalDependencies.artifacts.length > 0) { + MavenDependencyResolver resolver = new MavenDependencyResolver(dependenciesFolder); + for (String artifact : externalDependencies.artifacts) { + for (URL resolved : resolver.resolve(artifact, repoList)) { + addDependencyFile(resolved, discoveredExtension); + } + LOGGER.trace("Dependency of extension {}: {}", discoveredExtension.getName(), artifact); } - - repoList.add(new MavenRepository(repository.name, repository.url)); - } - - getter.addMavenResolver(repoList); - - for (String artifact : externalDependencies.artifacts) { - var resolved = getter.get(artifact, dependenciesFolder); - addDependencyFile(resolved, discoveredExtension); - LOGGER.trace("Dependency of extension {}: {}", discoveredExtension.getName(), resolved); } ExtensionClassLoader extensionClassLoader = discoveredExtension.getClassLoader(); @@ -593,20 +580,10 @@ private void loadDependencies(@NotNull List extensions) { } } - private void addDependencyFile(@NotNull ResolvedDependency dependency, @NotNull DiscoveredExtension extension) { - URL location = dependency.getContentsLocation(); + private void addDependencyFile(@NotNull URL location, @NotNull DiscoveredExtension extension) { extension.files.add(location); extension.getClassLoader().addURL(location); LOGGER.trace("Added dependency {} to extension {} classpath", location.toExternalForm(), extension.getName()); - - // recurse to add full dependency tree - if (!dependency.getSubdependencies().isEmpty()) { - LOGGER.trace("Dependency {} has subdependencies, adding...", location.toExternalForm()); - for (ResolvedDependency sub : dependency.getSubdependencies()) { - addDependencyFile(sub, extension); - } - LOGGER.trace("Dependency {} has had its subdependencies added.", location.toExternalForm()); - } } private boolean loadExtensionList(@NotNull List extensionsToLoad) { diff --git a/minestom-extensions/src/main/java/net/minestom/server/extensions/MavenDependencyResolver.java b/minestom-extensions/src/main/java/net/minestom/server/extensions/MavenDependencyResolver.java new file mode 100644 index 0000000..ced9531 --- /dev/null +++ b/minestom-extensions/src/main/java/net/minestom/server/extensions/MavenDependencyResolver.java @@ -0,0 +1,145 @@ +package net.minestom.server.extensions; + +import org.apache.maven.repository.internal.MavenRepositorySystemUtils; +import org.eclipse.aether.DefaultRepositorySystemSession; +import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.artifact.Artifact; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.collection.CollectRequest; +import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory; +import org.eclipse.aether.graph.Dependency; +import org.eclipse.aether.graph.DependencyNode; +import org.eclipse.aether.impl.DefaultServiceLocator; +import org.eclipse.aether.repository.LocalRepository; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.repository.RepositoryPolicy; +import org.eclipse.aether.resolution.DependencyRequest; +import org.eclipse.aether.resolution.DependencyResolutionException; +import org.eclipse.aether.spi.connector.RepositoryConnectorFactory; +import org.eclipse.aether.spi.connector.transport.TransporterFactory; +import org.eclipse.aether.transport.file.FileTransporterFactory; +import org.eclipse.aether.transport.http.HttpTransporterFactory; +import org.eclipse.aether.util.artifact.JavaScopes; +import org.eclipse.aether.util.graph.visitor.PreorderNodeListGenerator; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Resolves the {@code externalDependencies} of an extension from Maven repositories, downloading + * each artifact and its transitive dependencies into the extension libraries folder. + * + *

Backed by Maven Artifact Resolver (Aether) — the same resolver Maven itself uses, and the same + * approach Paper takes for its plugin library loader. + * + *

The wiring below looks old-fashioned on purpose. {@code RepositorySystemSupplier} is the + * modern entry point, but it builds a resolver with Aether's generic descriptor reader rather than + * the Maven-flavoured one, and that reader does not interpret POMs: resolution then silently + * returns the requested artifact with no transitive dependencies at all. Going through + * {@code MavenRepositorySystemUtils} is what pulls in the POM-aware reader. It is deprecated but + * not replaced for this use case. + */ +final class MavenDependencyResolver { + + private final RepositorySystem system; + private final DefaultRepositorySystemSession session; + + /** + * @param localRepository directory that caches downloaded artifacts + */ + MavenDependencyResolver(@NotNull File localRepository) { + final DefaultServiceLocator locator = MavenRepositorySystemUtils.newServiceLocator(); + locator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class); + // Both transports are registered: http(s) for remote repositories, file for local ones. + // Aether cannot fetch anything from a repository whose scheme has no transport. + locator.addService(TransporterFactory.class, HttpTransporterFactory.class); + locator.addService(TransporterFactory.class, FileTransporterFactory.class); + locator.setErrorHandler(new DefaultServiceLocator.ErrorHandler() { + @Override + public void serviceCreationFailed(Class type, Class impl, Throwable exception) { + ExtensionManager.LOGGER.error("Could not create the dependency resolver service {}", + type.getName(), exception); + } + }); + + this.system = locator.getService(RepositorySystem.class); + + this.session = MavenRepositorySystemUtils.newSession(); + this.session.setSystemProperties(System.getProperties()); + // A corrupted download is worse than a failed one: it would surface much later as an + // unexplainable linkage error inside the extension. + this.session.setChecksumPolicy(RepositoryPolicy.CHECKSUM_POLICY_FAIL); + this.session.setLocalRepositoryManager( + system.newLocalRepositoryManager(session, new LocalRepository(localRepository))); + } + + /** + * Resolves one Maven coordinate along with its transitive runtime dependencies. + * + * @param coordinate the artifact, as {@code group:artifact:version} + * @param repositories the repositories to search, in order + * @return the location of the resolved artifact and of every transitive dependency, the + * requested artifact first + * @throws DependencyResolutionException if the artifact or any of its dependencies is unavailable + * @throws MalformedURLException if a resolved file cannot be expressed as a URL + */ + @NotNull + List resolve(@NotNull String coordinate, @NotNull List repositories) + throws DependencyResolutionException, MalformedURLException { + final Artifact artifact = new DefaultArtifact(coordinate); + final CollectRequest collect = + new CollectRequest(new Dependency(artifact, JavaScopes.RUNTIME), repositories); + + final DependencyNode root = + system.resolveDependencies(session, new DependencyRequest(collect, null)).getRoot(); + + // Preorder keeps the requested artifact first and each dependency ahead of its own + // dependencies, which is the order the extension classloader should see them in. + final PreorderNodeListGenerator generator = new PreorderNodeListGenerator(); + root.accept(generator); + + // A diamond in the graph would otherwise add the same jar to the classloader twice. + final Set locations = new LinkedHashSet<>(); + for (Artifact resolved : generator.getArtifacts(false)) { + final File file = resolved.getFile(); + if (file == null) { + ExtensionManager.LOGGER.warn("Dependency {} resolved without a file, skipping it.", + resolved); + continue; + } + locations.add(file.toURI().toURL()); + } + return List.copyOf(locations); + } + + /** + * Builds the Aether repositories for an extension's declared repository list. + * + * @param repositories the repositories declared in {@code extension.json} + * @return the same repositories in Aether's representation + * @throws IllegalStateException if a declared repository has no name or no url + */ + @NotNull + static List toRemoteRepositories( + DiscoveredExtension.ExternalDependencies.@NotNull Repository @NotNull [] repositories) { + final List result = new ArrayList<>(repositories.length); + for (var repository : repositories) { + if (repository.name == null || repository.name.isEmpty()) { + throw new IllegalStateException("Missing 'name' element in repository object."); + } + + if (repository.url == null || repository.url.isEmpty()) { + throw new IllegalStateException("Missing 'url' element in repository object."); + } + + result.add(new RemoteRepository.Builder(repository.name, "default", repository.url).build()); + } + return result; + } +} diff --git a/minestom-extensions/src/test/java/net/minestom/server/extensions/DescriptorContractTest.java b/minestom-extensions/src/test/java/net/minestom/server/extensions/DescriptorContractTest.java new file mode 100644 index 0000000..a88a7d1 --- /dev/null +++ b/minestom-extensions/src/test/java/net/minestom/server/extensions/DescriptorContractTest.java @@ -0,0 +1,99 @@ +package net.minestom.server.extensions; + +import com.google.gson.Gson; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Pins the on-disk shape of {@code extension.json} to the fields {@link DiscoveredExtension} + * actually reads. + * + *

This exists because the contract has no compiler to enforce it. {@code + * minestom-extensions-processor} generates the descriptor but deliberately does not depend on this + * module, so nothing links the names it writes to the fields Gson populates here. And Gson silently + * ignores JSON members it does not recognise: renaming a field in {@link DiscoveredExtension} would + * not break a single compilation, every processor test would stay green, and extensions would just + * start losing that value at runtime. + * + *

{@code extension-descriptor-contract.json} is the exact output of the processor for a fully + * populated {@code @ExtensionInfo} - the processor's own test suite asserts it reproduces this file + * byte for byte. So if either end moves, one of the two suites goes red. + */ +class DescriptorContractTest { + + /** The generated descriptor both modules agree on. */ + static final String CONTRACT_RESOURCE = "/extension-descriptor-contract.json"; + + @Test + @DisplayName("the generated descriptor deserializes into every DiscoveredExtension field") + void generatedDescriptorPopulatesEveryField() throws IOException { + final DiscoveredExtension extension = readContract(); + DiscoveredExtension.verifyIntegrity(extension); + + assertAll( + () -> assertEquals("ContractExtension", extension.getName()), + () -> assertEquals("com.example.ContractExtension", extension.getEntrypoint()), + () -> assertEquals("4.2.0", extension.getVersion()), + // Non-ASCII is escaped as \ uXXXX by the processor; it has to survive the round trip. + () -> assertArrayEquals(new String[]{"TheMeinerLP", "Jörg Müller"}, + extension.getAuthors()), + () -> assertArrayEquals(new String[]{"FirstDependency", "SecondDependency"}, + extension.getDependencies())); + } + + @Test + @DisplayName("the nested externalDependencies structure is populated, not silently dropped") + void externalDependenciesArePopulated() throws IOException { + final DiscoveredExtension extension = readContract(); + DiscoveredExtension.verifyIntegrity(extension); + + final DiscoveredExtension.ExternalDependencies external = extension.getExternalDependencies(); + assertNotNull(external, "externalDependencies must not be null after verifyIntegrity"); + + assertAll( + () -> assertArrayEquals( + new String[]{"com.google.guava:guava:33.4.0-jre", + "org.apache.commons:commons-lang3:3.17.0"}, + external.artifacts), + () -> assertEquals(2, external.repositories.length), + () -> assertEquals("central", external.repositories[0].name), + () -> assertEquals("https://repo1.maven.org/maven2/", external.repositories[0].url), + () -> assertEquals("onelitefeather", external.repositories[1].name), + () -> assertEquals("https://repo.onelitefeather.dev/releases", + external.repositories[1].url)); + } + + @Test + @DisplayName("a descriptor generated by the processor loads successfully") + void contractDescriptorLoadsSuccessfully() throws IOException { + final DiscoveredExtension extension = readContract(); + DiscoveredExtension.verifyIntegrity(extension); + + assertEquals(DiscoveredExtension.LoadStatus.LOAD_SUCCESS, extension.loadStatus); + // meta is not written by the processor and must be defaulted rather than left null. + assertNotNull(extension.getMeta()); + assertEquals(0, extension.getMeta().size()); + } + + private static DiscoveredExtension readContract() throws IOException { + try (InputStream in = DescriptorContractTest.class.getResourceAsStream(CONTRACT_RESOURCE)) { + assertNotNull(in, "missing test resource " + CONTRACT_RESOURCE); + // Deliberately UTF-8 rather than the platform default: the point of the escaping in the + // processor is that the file is pure ASCII, so this must not depend on the charset. + try (Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + return new Gson().fromJson(reader, DiscoveredExtension.class); + } + } + } +} diff --git a/minestom-extensions/src/test/java/net/minestom/server/extensions/MavenDependencyResolverTest.java b/minestom-extensions/src/test/java/net/minestom/server/extensions/MavenDependencyResolverTest.java new file mode 100644 index 0000000..98137c4 --- /dev/null +++ b/minestom-extensions/src/test/java/net/minestom/server/extensions/MavenDependencyResolverTest.java @@ -0,0 +1,186 @@ +package net.minestom.server.extensions; + +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.resolution.DependencyResolutionException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the runtime resolution of an extension's {@code externalDependencies}. + * + *

Everything is served from a Maven repository laid out in a temporary directory and reached + * over {@code file://}, so the suite neither touches the network nor depends on Maven Central's + * availability or terms of service. + * + *

The transitive case is the one that matters. A resolver that returns only the requested + * artifact still looks like it works — the extension loads, and the failure surfaces much later as + * a {@code NoClassDefFoundError} from inside the extension. That is exactly what the previous + * implementation degraded into, so it is asserted explicitly here. + */ +class MavenDependencyResolverTest { + + @Test + @DisplayName("a transitive dependency is resolved along with the requested artifact") + void resolvesTransitiveDependencies(@TempDir Path dir) throws Exception { + final Path repo = repositoryWithTransitiveChain(dir.resolve("repo")); + final MavenDependencyResolver resolver = + new MavenDependencyResolver(dir.resolve("cache").toFile()); + + final List resolved = + resolver.resolve("com.example:lib-a:1.0", List.of(fileRepository(repo))); + + assertEquals(2, resolved.size(), () -> "expected lib-a and its transitive lib-b, got " + resolved); + assertAll( + // Preorder: the requested artifact comes first, its dependency after it. + () -> assertTrue(resolved.get(0).toString().endsWith("lib-a-1.0.jar"), resolved::toString), + () -> assertTrue(resolved.get(1).toString().endsWith("lib-b-1.0.jar"), resolved::toString)); + } + + @Test + @DisplayName("resolved artifacts are downloaded into the local repository") + void downloadsIntoLocalRepository(@TempDir Path dir) throws Exception { + final Path repo = repositoryWithTransitiveChain(dir.resolve("repo")); + final Path cache = dir.resolve("cache"); + + new MavenDependencyResolver(cache.toFile()) + .resolve("com.example:lib-a:1.0", List.of(fileRepository(repo))); + + assertAll( + () -> assertTrue(Files.exists(cache.resolve("com/example/lib-a/1.0/lib-a-1.0.jar")), + "lib-a was not cached"), + () -> assertTrue(Files.exists(cache.resolve("com/example/lib-b/1.0/lib-b-1.0.jar")), + "the transitive lib-b was not cached")); + } + + @Test + @DisplayName("an unavailable artifact fails instead of resolving to nothing") + void missingArtifactFails(@TempDir Path dir) throws Exception { + final Path repo = repositoryWithTransitiveChain(dir.resolve("repo")); + final MavenDependencyResolver resolver = + new MavenDependencyResolver(dir.resolve("cache").toFile()); + + assertThrows(DependencyResolutionException.class, + () -> resolver.resolve("com.example:does-not-exist:9.9", List.of(fileRepository(repo)))); + } + + @Test + @DisplayName("a repository without a name or url is rejected") + void invalidRepositoryIsRejected() { + final var noName = new DiscoveredExtension.ExternalDependencies.Repository(); + noName.url = "https://example.invalid/repo"; + final var noUrl = new DiscoveredExtension.ExternalDependencies.Repository(); + noUrl.name = "example"; + + assertAll( + () -> assertThrows(IllegalStateException.class, () -> MavenDependencyResolver + .toRemoteRepositories(new DiscoveredExtension.ExternalDependencies.Repository[]{noName})), + () -> assertThrows(IllegalStateException.class, () -> MavenDependencyResolver + .toRemoteRepositories(new DiscoveredExtension.ExternalDependencies.Repository[]{noUrl}))); + } + + @Test + @DisplayName("declared repositories are translated into Aether repositories in order") + void repositoriesAreTranslated() { + final var first = new DiscoveredExtension.ExternalDependencies.Repository(); + first.name = "central"; + first.url = "https://repo1.maven.org/maven2/"; + final var second = new DiscoveredExtension.ExternalDependencies.Repository(); + second.name = "onelitefeather"; + second.url = "https://repo.onelitefeather.dev/releases"; + + final List repositories = MavenDependencyResolver.toRemoteRepositories( + new DiscoveredExtension.ExternalDependencies.Repository[]{first, second}); + + assertAll( + () -> assertEquals(2, repositories.size()), + () -> assertEquals("central", repositories.get(0).getId()), + () -> assertEquals("https://repo1.maven.org/maven2/", repositories.get(0).getUrl()), + () -> assertEquals("onelitefeather", repositories.get(1).getId()), + () -> assertEquals("https://repo.onelitefeather.dev/releases", repositories.get(1).getUrl())); + } + + private static RemoteRepository fileRepository(Path repo) { + return new RemoteRepository.Builder("test", "default", repo.toUri().toString()).build(); + } + + /** Lays out {@code lib-a:1.0} depending on {@code lib-b:1.0} as a real Maven repository. */ + private static Path repositoryWithTransitiveChain(Path repo) throws Exception { + deploy(repo, "lib-b", """ + + 4.0.0 + com.example + lib-b + 1.0 + + """); + deploy(repo, "lib-a", """ + + 4.0.0 + com.example + lib-a + 1.0 + + + com.example + lib-b + 1.0 + + + + """); + return repo; + } + + /** Writes a pom and a (minimal but valid) jar, each with the checksums Aether verifies. */ + private static void deploy(Path repo, String artifactId, String pom) throws Exception { + final Path dir = repo.resolve("com/example/" + artifactId + "/1.0"); + Files.createDirectories(dir); + + writeWithChecksums(dir.resolve(artifactId + "-1.0.pom"), pom.getBytes(StandardCharsets.UTF_8)); + writeWithChecksums(dir.resolve(artifactId + "-1.0.jar"), emptyJar(artifactId)); + } + + private static byte[] emptyJar(String artifactId) throws IOException { + final var bytes = new java.io.ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + zip.putNextEntry(new ZipEntry("com/example/" + artifactId + "/Marker.class")); + zip.write(new byte[]{(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE}); + zip.closeEntry(); + } + return bytes.toByteArray(); + } + + /** + * The resolver runs with {@code CHECKSUM_POLICY_FAIL}, so every artifact needs matching + * checksums next to it — writing them here also keeps that policy under test. + */ + private static void writeWithChecksums(Path file, byte[] content) throws Exception { + try (OutputStream out = Files.newOutputStream(file)) { + out.write(content); + } + for (String algorithm : List.of("SHA-1", "MD5")) { + final byte[] digest = MessageDigest.getInstance(algorithm).digest(content); + final String hex = String.format("%0" + (digest.length * 2) + "x", new BigInteger(1, digest)); + final String suffix = algorithm.equals("SHA-1") ? ".sha1" : ".md5"; + Files.writeString(file.resolveSibling(file.getFileName() + suffix), hex); + } + } +} diff --git a/minestom-extensions/src/test/resources/extension-descriptor-contract.json b/minestom-extensions/src/test/resources/extension-descriptor-contract.json new file mode 100644 index 0000000..6442cb6 --- /dev/null +++ b/minestom-extensions/src/test/resources/extension-descriptor-contract.json @@ -0,0 +1,29 @@ +{ + "name": "ContractExtension", + "entrypoint": "com.example.ContractExtension", + "version": "4.2.0", + "authors": [ + "TheMeinerLP", + "J\u00f6rg M\u00fcller" + ], + "dependencies": [ + "FirstDependency", + "SecondDependency" + ], + "externalDependencies": { + "repositories": [ + { + "name": "central", + "url": "https://repo1.maven.org/maven2/" + }, + { + "name": "onelitefeather", + "url": "https://repo.onelitefeather.dev/releases" + } + ], + "artifacts": [ + "com.google.guava:guava:33.4.0-jre", + "org.apache.commons:commons-lang3:3.17.0" + ] + } +} diff --git a/renovate.json b/renovate.json index d508a6a..8b94cf2 100644 --- a/renovate.json +++ b/renovate.json @@ -3,5 +3,50 @@ "extends": [ "github>OneLiteFeatherNET/renovate:default(OneLiteFeatherNET/minestom-extensions-maintainers)", "github>OneLiteFeatherNET/renovate:minestom" + ], + "customManagers": [ + { + "description": "Maven coordinates shown as examples in the README, so the documented versions do not rot", + "customType": "regex", + "managerFilePatterns": [ + "/^README\\.md$/" + ], + "datasourceTemplate": "maven", + "matchStrings": [ + "(?[a-z][a-z0-9_-]*(?:\\.[a-z0-9_-]+)+:[a-zA-Z0-9_.-]+):(?\\d[a-zA-Z0-9_.-]*)" + ] + }, + { + "description": "maven-compiler-plugin version in the README's Maven setup example", + "customType": "regex", + "managerFilePatterns": [ + "/^README\\.md$/" + ], + "datasourceTemplate": "maven", + "depNameTemplate": "org.apache.maven.plugins:maven-compiler-plugin", + "matchStrings": [ + "maven-compiler-plugin\\s*(?[^<]+)" + ] + } + ], + "packageRules": [ + { + "description": "This project's own artifacts are versioned by release-please. The README shows them as placeholders today, but a future edit could paste a real version in.", + "matchFileNames": [ + "README.md" + ], + "matchPackageNames": [ + "net.onelitefeather:**" + ], + "enabled": false + }, + { + "description": "README examples are documentation - one grouped PR beats a stream of individual ones", + "matchFileNames": [ + "README.md" + ], + "groupName": "README example versions", + "semanticCommitType": "docs" + } ] } diff --git a/settings.gradle.kts b/settings.gradle.kts index db26ac1..f5c2712 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,11 @@ rootProject.name = "minestom-extensions" +include("minestom-extensions") +include("minestom-extensions-processor") +include("minestom-extensions-gradle-plugin") +include("minestom-extensions-maven-plugin") +include("minestom-extensions-bom") + dependencyResolutionManagement { repositories { mavenCentral() @@ -23,15 +29,29 @@ dependencyResolutionManagement { versionCatalogs { create("libs") { version("mycelium-bom", "1.7.1") - version("dependency-getter", "v1.0.1") version("logback-classic", "1.4.5") version("slf4j2", "2.0.18") + version("gson", "2.13.2") + // maven-resolver-provider carries the Maven-flavoured ArtifactDescriptorReader that + // understands POMs; the resolver modules must match the version it depends on, so keep + // these two in lockstep when bumping. + version("maven-resolver-provider", "3.9.12") + version("maven-resolver", "1.9.25") + version("maven-plugin-api", "3.9.16") + version("maven-plugin-annotations", "3.15.2") library("myclium-bom", "net.onelitefeather", "mycelium-bom").versionRef("mycelium-bom") - library("dependency-getter", "com.github.Minestom", "DependencyGetter").versionRef("dependency-getter") + library("maven-resolver-provider", "org.apache.maven", "maven-resolver-provider").versionRef("maven-resolver-provider") + library("maven-resolver-connector-basic", "org.apache.maven.resolver", "maven-resolver-connector-basic").versionRef("maven-resolver") + library("maven-resolver-transport-http", "org.apache.maven.resolver", "maven-resolver-transport-http").versionRef("maven-resolver") + library("maven-resolver-transport-file", "org.apache.maven.resolver", "maven-resolver-transport-file").versionRef("maven-resolver") + library("maven-plugin-api", "org.apache.maven", "maven-plugin-api").versionRef("maven-plugin-api") + library("maven-core", "org.apache.maven", "maven-core").versionRef("maven-plugin-api") + library("maven-plugin-annotations", "org.apache.maven.plugin-tools", "maven-plugin-annotations").versionRef("maven-plugin-annotations") library("slf4j2", "org.slf4j", "slf4j-api").versionRef("slf4j2") library("minestom", "net.minestom", "minestom").withoutVersion() library("logback-classic", "ch.qos.logback", "logback-classic").versionRef("logback-classic") + library("gson", "com.google.code.gson", "gson").versionRef("gson") library("junit.api", "org.junit.jupiter", "junit-jupiter-api").withoutVersion() library("junit.engine", "org.junit.jupiter", "junit-jupiter-engine").withoutVersion()