diff --git a/build.gradle.kts b/build.gradle.kts
index 760d26e87..1df16e2a9 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -127,6 +127,7 @@ dependencies {
bundledPlugin("org.jetbrains.kotlin")
bundledPlugin("org.toml.lang")
bundledPlugin("org.jetbrains.plugins.yaml")
+ bundledPlugin("org.jetbrains.plugins.github")
testFramework(TestFrameworkType.JUnit5)
testFramework(TestFrameworkType.Platform)
diff --git a/buildSrc/src/main/kotlin/mcdev-core.gradle.kts b/buildSrc/src/main/kotlin/mcdev-core.gradle.kts
index b23c1c945..46b4228e6 100644
--- a/buildSrc/src/main/kotlin/mcdev-core.gradle.kts
+++ b/buildSrc/src/main/kotlin/mcdev-core.gradle.kts
@@ -193,6 +193,7 @@ idea {
tasks.runIde {
maxHeapSize = "2G"
jvmArgs("--add-exports=java.base/jdk.internal.vm=ALL-UNNAMED")
+ systemProperty("mcdev.annotater.upstream", "Earthcomputer/annotater")
}
tasks.test {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0994211a1..015cf580a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -39,7 +39,7 @@ changelog-plugin = { module = "org.jetbrains.changelog:org.jetbrains.changelog.g
coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
-mappingIo = "net.fabricmc:mapping-io:0.8.0"
+mappingIo = "net.fabricmc:mapping-io:0.9.2"
mixinExtras-expressions = "io.github.llamalad7:mixinextras-expressions:0.0.6"
jgraphx = "com.github.vlsi.mxgraph:jgraphx:4.2.2"
diff --git a/src/main/kotlin/asset/GeneralAssets.kt b/src/main/kotlin/asset/GeneralAssets.kt
index 8fdf6cec5..7a29b2622 100644
--- a/src/main/kotlin/asset/GeneralAssets.kt
+++ b/src/main/kotlin/asset/GeneralAssets.kt
@@ -25,4 +25,7 @@ object GeneralAssets : Assets() {
val PLUGIN = loadIcon("/assets/icons/general/plugin.png")
val MC_TEMPLATE = loadIcon("/assets/icons/general/MinecraftTemplate.png")
+
+ val TINY_POTATO = loadIcon("/assets/icons/general/tiny_potato.png")
+ val TINY_POTATO_LARGE = loadIcon("/assets/icons/general/tiny_potato_large.png")
}
diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt
new file mode 100644
index 000000000..0db07f545
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt
@@ -0,0 +1,36 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocStore
+import java.nio.file.Path
+
+/**
+ * @property managed whether the plugin created this checkout and may run git operations in it
+ */
+class AnnotaterCheckout(val root: Path, val config: AnnotaterConfig, val managed: Boolean) {
+ val version: String
+ get() = config.version
+
+ val mappingsDir: Path = root.resolve(config.mappings)
+
+ val javadocStore = AnnotaterJavadocStore.forDirectory(mappingsDir)
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt
new file mode 100644
index 000000000..b298c8af0
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt
@@ -0,0 +1,204 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.demonwav.mcdev.facet.MinecraftFacet
+import com.demonwav.mcdev.platform.fabric.FabricModuleType
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocStore
+import com.demonwav.mcdev.platform.minecraft.MinecraftModuleSettings
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.module.ModuleManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.roots.ProjectFileIndex
+import com.intellij.openapi.util.Key
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.psi.PsiElement
+import java.io.IOException
+import java.nio.file.Path
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.io.path.Path
+import kotlin.io.path.exists
+
+@Service(Service.Level.APP)
+class AnnotaterCheckoutService {
+ private val checkouts = ConcurrentHashMap()
+
+ init {
+ ApplicationManager.getApplication().messageBus.connect().subscribe(
+ AnnotaterListener.TOPIC,
+ object : AnnotaterListener {
+ override fun checkoutsChanged() {
+ checkouts.clear()
+ AnnotaterJavadocStore.invalidateAll()
+ }
+ },
+ )
+ }
+
+ sealed class Status {
+ data class Available(val checkout: AnnotaterCheckout) : Status()
+ data object Disabled : Status()
+ data object UnknownMinecraftVersion : Status()
+ data class NotCloned(val version: String) : Status()
+ data class VersionMismatch(val expected: String, val actual: String) : Status()
+ data class Invalid(val root: Path, val message: String) : Status()
+ }
+
+ /**
+ * Ignores whether annotater is enabled.
+ */
+ private fun statusForVersion(version: String): Status {
+ val settings = AnnotaterSettings.instance
+ return when (settings.checkoutMode) {
+ AnnotaterSettings.CheckoutMode.MANAGED -> {
+ val root = managedWorktree(version)
+ if (!root.resolve(AnnotaterConfig.FILE_NAME).exists()) {
+ return Status.NotCloned(version)
+ }
+ load(root, managed = true)
+ }
+
+ AnnotaterSettings.CheckoutMode.CUSTOM -> {
+ if (settings.customDirectory.isBlank()) {
+ return Status.Invalid(Path(""), "No annotater directory selected")
+ }
+ when (val status = load(Path(settings.customDirectory), managed = false)) {
+ is Status.Available -> if (status.checkout.version != version) {
+ Status.VersionMismatch(version, status.checkout.version)
+ } else {
+ status
+ }
+
+ else -> status
+ }
+ }
+ }
+ }
+
+ fun checkoutForVersion(version: String): AnnotaterCheckout? {
+ return (statusForVersion(version) as? Status.Available)?.checkout
+ }
+
+ fun status(element: PsiElement): Status {
+ if (!AnnotaterSettings.instance.enabled) {
+ return Status.Disabled
+ }
+ val file = virtualFileOf(element) ?: return Status.UnknownMinecraftVersion
+ val version = minecraftVersion(element.project, file) ?: return Status.UnknownMinecraftVersion
+ return statusForVersion(version)
+ }
+
+ fun checkoutFor(element: PsiElement): AnnotaterCheckout? {
+ if (!AnnotaterSettings.instance.enabled) {
+ return null
+ }
+ val file = virtualFileOf(element) ?: return null
+ return checkoutFor(element.project, file)
+ }
+
+ fun checkoutFor(project: Project, file: VirtualFile): AnnotaterCheckout? {
+ if (!AnnotaterSettings.instance.enabled || !isMinecraftFile(project, file)) {
+ return null
+ }
+ val version = minecraftVersion(project, file) ?: return null
+ return checkoutForVersion(version)
+ }
+
+ /**
+ * Updates cached data after files changed on disk, returning the root and outer class of each changed Javadoc
+ * file, and whether a `config.json` changed.
+ */
+ fun filesChanged(paths: Collection): Pair>, Boolean> {
+ val changedJavadocs = mutableListOf>()
+ var configChanged = false
+ for (checkout in checkouts.values) {
+ for (path in paths) {
+ if (path == checkout.root.resolve(AnnotaterConfig.FILE_NAME)) {
+ configChanged = true
+ } else if (path.startsWith(checkout.mappingsDir) && path.fileName.toString().endsWith(".mapping")) {
+ val outerClass = checkout.mappingsDir.relativize(path).joinToString("/").removeSuffix(".mapping")
+ checkout.javadocStore.invalidate(outerClass)
+ changedJavadocs += checkout.root to outerClass
+ }
+ }
+ }
+ return changedJavadocs to configChanged
+ }
+
+ fun managedWorktree(version: String): Path = AnnotaterSettings.instance.effectiveManagedRoot.resolve(version)
+
+ private fun load(root: Path, managed: Boolean): Status {
+ checkouts[root]?.let { return Status.Available(it) }
+ return try {
+ val checkout = AnnotaterCheckout(root, AnnotaterConfig.read(root), managed)
+ Status.Available(checkouts.putIfAbsent(root, checkout) ?: checkout)
+ } catch (e: IOException) {
+ thisLogger().debug("Failed to read annotater checkout at $root", e)
+ Status.Invalid(root, e.message ?: e.toString())
+ }
+ }
+
+ companion object {
+ private val IS_MINECRAFT_ROOT = Key.create("mcdev.annotater.isMinecraftRoot")
+
+ val instance: AnnotaterCheckoutService
+ get() = ApplicationManager.getApplication().getService(AnnotaterCheckoutService::class.java)
+
+ fun isFabricProject(project: Project): Boolean {
+ return ModuleManager.getInstance(project).modules.any {
+ MinecraftFacet.getInstance(it, FabricModuleType) != null
+ }
+ }
+
+ /**
+ * Elements of a class file's decompiled text are in the class file.
+ */
+ fun virtualFileOf(element: PsiElement): VirtualFile? = element.containingFile?.originalFile?.virtualFile
+
+ fun isMinecraftFile(project: Project, file: VirtualFile): Boolean {
+ val fileIndex = ProjectFileIndex.getInstance(project)
+ if (!fileIndex.isInLibrary(file)) {
+ return false
+ }
+ val root = fileIndex.getClassRootForFile(file) ?: fileIndex.getSourceRootForFile(file) ?: return false
+ root.getUserData(IS_MINECRAFT_ROOT)?.let { return it }
+ val isMinecraft = root.findFileByRelativePath("net/minecraft") != null
+ root.putUserData(IS_MINECRAFT_ROOT, isMinecraft)
+ return isMinecraft
+ }
+
+ /**
+ * The Minecraft version of the modules that depend on the library containing [file].
+ */
+ fun minecraftVersion(project: Project, file: VirtualFile): String? {
+ val fileIndex = ProjectFileIndex.getInstance(project)
+ val modules = fileIndex.getOrderEntriesForFile(file).asSequence().map { it.ownerModule } +
+ ModuleManager.getInstance(project).modules.asSequence()
+ return modules
+ .mapNotNull { module ->
+ if (module.isDisposed) null else MinecraftModuleSettings.getInstance(module).state.minecraftVersion
+ }
+ .firstOrNull { it.isNotBlank() }
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt
new file mode 100644
index 000000000..9715fa33a
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt
@@ -0,0 +1,76 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.google.gson.Gson
+import com.google.gson.JsonParseException
+import com.intellij.openapi.util.io.FileUtil
+import java.io.IOException
+import java.nio.file.Path
+import kotlin.io.path.exists
+import kotlin.io.path.readText
+
+data class AnnotaterConfig(
+ val version: String,
+ val mappings: String,
+ val unpickDefinitions: String?,
+) {
+ companion object {
+ const val FILE_NAME = "config.json"
+
+ private class Raw(
+ val version: String? = null,
+ val mappings: String? = null,
+ val unpickDefinitions: String? = null,
+ )
+
+ @Throws(IOException::class)
+ fun read(root: Path): AnnotaterConfig {
+ val file = root.resolve(FILE_NAME)
+ if (!file.exists()) {
+ throw IOException("$root does not contain a $FILE_NAME file")
+ }
+ val raw = try {
+ Gson().fromJson(file.readText(), Raw::class.java)
+ } catch (e: JsonParseException) {
+ throw IOException("Invalid $FILE_NAME: ${e.message}", e)
+ } ?: throw IOException("$FILE_NAME is empty")
+
+ val version = raw.version?.takeIf { it.isNotBlank() }
+ ?: throw IOException("$FILE_NAME is missing \"version\"")
+ val mappings = validatePath(root, raw.mappings ?: throw IOException("$FILE_NAME is missing \"mappings\""))
+ val unpick = raw.unpickDefinitions?.let { validatePath(root, it) }
+ return AnnotaterConfig(version, mappings, unpick)
+ }
+
+ /**
+ * Paths must be relative paths to subdirectories, like mcsrc requires.
+ */
+ @Throws(IOException::class)
+ private fun validatePath(root: Path, path: String): String {
+ val resolved = runCatching { root.resolve(path).normalize() }.getOrNull()
+ if (resolved == null || !FileUtil.isAncestor(root.normalize().toFile(), resolved.toFile(), true)) {
+ throw IOException("Invalid path in $FILE_NAME: \"$path\"")
+ }
+ return path
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt
new file mode 100644
index 000000000..aef9fcd27
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt
@@ -0,0 +1,251 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.demonwav.mcdev.asset.GeneralAssets
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.minecraft.MinecraftModuleSettings
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
+import com.intellij.openapi.module.ModuleManager
+import com.intellij.openapi.options.Configurable
+import com.intellij.openapi.options.ShowSettingsUtil
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.project.ProjectManager
+import com.intellij.openapi.ui.DialogPanel
+import com.intellij.openapi.ui.Messages
+import com.intellij.ui.components.JBCheckBox
+import com.intellij.ui.components.JBRadioButton
+import com.intellij.ui.components.JBTextField
+import com.intellij.ui.dsl.builder.AlignX
+import com.intellij.ui.dsl.builder.BottomGap
+import com.intellij.ui.dsl.builder.Cell
+import com.intellij.ui.dsl.builder.bind
+import com.intellij.ui.dsl.builder.bindSelected
+import com.intellij.ui.dsl.builder.bindText
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.builder.selected
+import java.awt.Font
+import java.io.IOException
+import javax.swing.JComponent
+import javax.swing.JLabel
+import javax.swing.SwingConstants
+import kotlin.io.path.Path
+import org.jetbrains.annotations.Nls
+
+class AnnotaterConfigurable : Configurable {
+ private lateinit var panel: DialogPanel
+
+ @Nls
+ override fun getDisplayName() = MCDevBundle("fabric.annotater.settings.display_name")
+
+ override fun createComponent(): JComponent = panel {
+ row(
+ JLabel(
+ MCDevBundle("fabric.annotater.settings.title"),
+ GeneralAssets.TINY_POTATO_LARGE,
+ SwingConstants.LEADING,
+ )
+ .apply { font = font.deriveFont(Font.BOLD, font.size * 1.5f) },
+ ) {}.bottomGap(BottomGap.MEDIUM)
+
+ val settings = AnnotaterSettings.instance
+ lateinit var enabled: Cell
+ row {
+ enabled = checkBox(MCDevBundle("fabric.annotater.settings.enabled"))
+ .bindSelected(settings::enabled)
+ .comment(MCDevBundle("fabric.annotater.settings.enabled.comment"))
+ }
+ indent {
+ row {
+ text(contributionGuidelinesNote())
+ }
+ row {
+ checkBox(MCDevBundle("fabric.annotater.settings.show_javadocs"))
+ .bindSelected(settings::showAnnotaterJavadocs)
+ }
+ separator()
+ lateinit var managed: Cell
+ lateinit var custom: Cell
+ buttonsGroup {
+ row {
+ managed = radioButton(
+ MCDevBundle("fabric.annotater.settings.managed"),
+ AnnotaterSettings.CheckoutMode.MANAGED,
+ )
+ }
+ indent {
+ row(MCDevBundle("fabric.annotater.settings.managed_root")) {
+ textFieldWithBrowseButton(FileChooserDescriptorFactory.singleDir())
+ .bindText(settings::managedRoot)
+ .applyToComponent {
+ (textField as? JBTextField)?.emptyText?.text =
+ AnnotaterSettings.defaultManagedRoot.toString()
+ }
+ .align(AlignX.FILL)
+ .enabledIf(managed.selected)
+ }
+ row {
+ val vcs = AnnotaterVcs.instance
+ button(MCDevBundle("fabric.annotater.settings.clone_now")) {
+ cloneOrUpdate(vcs ?: return@button)
+ }.enabled(vcs != null).enabledIf(managed.selected)
+ if (vcs == null) {
+ comment(MCDevBundle("fabric.annotater.settings.no_git"))
+ }
+ }
+ }
+ row {
+ custom = radioButton(
+ MCDevBundle("fabric.annotater.settings.custom"),
+ AnnotaterSettings.CheckoutMode.CUSTOM,
+ )
+ }
+ indent {
+ row {
+ textFieldWithBrowseButton(FileChooserDescriptorFactory.singleDir())
+ .bindText(settings::customDirectory)
+ .align(AlignX.FILL)
+ .enabledIf(custom.selected)
+ .validationOnInput { field ->
+ val error = validateCustomDirectory(field.text)
+ when {
+ error == null -> null
+ error.second -> error(error.first)
+ else -> warning(error.first)
+ }
+ }
+ .comment(MCDevBundle("fabric.annotater.settings.custom.comment"))
+ }
+ }
+ }.bind(settings::checkoutMode)
+ }.enabledIf(enabled.selected)
+
+ onApply {
+ ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged()
+ }
+ }.also { panel = it }
+
+ /**
+ * Returns the problem and whether it's an error rather than a warning.
+ */
+ private fun validateCustomDirectory(directory: String): Pair? {
+ if (directory.isBlank()) {
+ return null
+ }
+ val config = try {
+ AnnotaterConfig.read(Path(directory))
+ } catch (e: IOException) {
+ return MCDevBundle("fabric.annotater.status.invalid", e.message ?: e.toString()) to true
+ }
+ val versions = knownMinecraftVersions()
+ if (versions.isNotEmpty() && config.version !in versions) {
+ val message = MCDevBundle(
+ "fabric.annotater.settings.custom.version_mismatch",
+ config.version,
+ versions.joinToString(", "),
+ )
+ return message to false
+ }
+ return null
+ }
+
+ /**
+ * The guidelines can differ between version branches, so link to those of each version in use.
+ */
+ private fun contributionGuidelinesNote(): String {
+ fun link(branch: String, text: String) =
+ "$text"
+
+ val versions = knownMinecraftVersions()
+ val links = when (versions.size) {
+ 0 -> link("HEAD", MCDevBundle("fabric.annotater.settings.guidelines.link"))
+ 1 -> link(versions.single(), MCDevBundle("fabric.annotater.settings.guidelines.link"))
+ else -> MCDevBundle(
+ "fabric.annotater.settings.guidelines.link.version",
+ versions.joinToString(", ") { link(it, it) },
+ )
+ }
+ return MCDevBundle("fabric.annotater.settings.guidelines", links)
+ }
+
+ private fun cloneOrUpdate(vcs: AnnotaterVcs) {
+ val versions = knownMinecraftVersions()
+ val version = when (versions.size) {
+ 0 -> {
+ Messages.showWarningDialog(
+ panel,
+ MCDevBundle("fabric.annotater.settings.clone_now.no_versions"),
+ MCDevBundle("fabric.annotater.settings.clone_now"),
+ )
+ return
+ }
+
+ 1 -> versions.single()
+ else -> Messages.showEditableChooseDialog(
+ MCDevBundle("fabric.annotater.settings.clone_now.version"),
+ MCDevBundle("fabric.annotater.settings.clone_now"),
+ null,
+ versions.toTypedArray(),
+ versions.first(),
+ null,
+ )?.takeIf { it.isNotBlank() } ?: return
+ }
+
+ // Clone into the directory currently in the form, even if it hasn't been applied yet
+ panel.apply()
+ vcs.cloneOrUpdate(null, version, modal = true) { result ->
+ result.onSuccess { checkout ->
+ Messages.showInfoMessage(
+ panel,
+ MCDevBundle("fabric.annotater.cloned.content", checkout.root),
+ MCDevBundle("fabric.annotater.cloned", version),
+ )
+ }.onFailure { error ->
+ Messages.showErrorDialog(
+ panel,
+ error.message ?: error.toString(),
+ MCDevBundle("fabric.annotater.clone_failed"),
+ )
+ }
+ }
+ }
+
+ override fun isModified(): Boolean = panel.isModified()
+
+ override fun apply() = panel.apply()
+
+ override fun reset() = panel.reset()
+
+ private fun knownMinecraftVersions(): List {
+ return ProjectManager.getInstance().openProjects.flatMap { project ->
+ ModuleManager.getInstance(project).modules.mapNotNull {
+ MinecraftModuleSettings.getInstance(it).state.minecraftVersion
+ }
+ }.filter { it.isNotBlank() }.distinct()
+ }
+
+ object Util {
+ fun show(project: Project?) {
+ ShowSettingsUtil.getInstance().showSettingsDialog(project, AnnotaterConfigurable::class.java)
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt
new file mode 100644
index 000000000..d6a6834a5
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt
@@ -0,0 +1,41 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.intellij.util.messages.Topic
+import java.nio.file.Path
+
+interface AnnotaterListener {
+ /**
+ * Called when annotater settings change, or a checkout is created, updated or switches branch.
+ */
+ fun checkoutsChanged() {}
+
+ /**
+ * Called when the Javadocs of [outerClass] (an internal name) in the checkout at [checkoutRoot] change.
+ */
+ fun javadocsChanged(checkoutRoot: Path, outerClass: String) {}
+
+ companion object {
+ @Topic.AppLevel
+ val TOPIC = Topic(AnnotaterListener::class.java, Topic.BroadcastDirection.NONE)
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt
new file mode 100644
index 000000000..30fac8eaa
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt
@@ -0,0 +1,80 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.intellij.notification.Notification
+import com.intellij.notification.NotificationAction
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.project.Project
+
+object AnnotaterNotifications {
+ const val GROUP_ID = "Minecraft annotater"
+
+ private fun describe(status: AnnotaterCheckoutService.Status): String = when (status) {
+ is AnnotaterCheckoutService.Status.Available -> status.checkout.root.toString()
+ AnnotaterCheckoutService.Status.Disabled -> MCDevBundle("fabric.annotater.status.disabled")
+ AnnotaterCheckoutService.Status.UnknownMinecraftVersion ->
+ MCDevBundle("fabric.annotater.status.unknown_version")
+
+ is AnnotaterCheckoutService.Status.NotCloned -> if (AnnotaterVcs.instance != null) {
+ MCDevBundle("fabric.annotater.status.not_cloned", status.version)
+ } else {
+ MCDevBundle("fabric.annotater.status.not_cloned_no_git", status.version)
+ }
+
+ is AnnotaterCheckoutService.Status.VersionMismatch ->
+ MCDevBundle("fabric.annotater.status.version_mismatch", status.actual, status.expected)
+
+ is AnnotaterCheckoutService.Status.Invalid -> MCDevBundle("fabric.annotater.status.invalid", status.message)
+ }
+
+ fun notifyUnavailable(project: Project, status: AnnotaterCheckoutService.Status) {
+ val notification = Notification(
+ GROUP_ID,
+ MCDevBundle("fabric.annotater.unavailable"),
+ describe(status),
+ NotificationType.WARNING,
+ )
+ val vcs = AnnotaterVcs.instance
+ if (status is AnnotaterCheckoutService.Status.NotCloned && vcs != null) {
+ notification.addAction(
+ NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.clone", status.version)) {
+ vcs.cloneOrUpdate(project, status.version)
+ },
+ )
+ }
+ notification.addAction(
+ NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.configure")) {
+ AnnotaterConfigurable.Util.show(project)
+ },
+ )
+ notification.notify(project)
+ }
+
+ fun notifyError(project: Project?, title: String, error: Throwable) {
+ Notification(GROUP_ID, title, error.message ?: error.toString(), NotificationType.ERROR).notify(project)
+ }
+
+ fun notifyInfo(project: Project?, title: String, content: String) {
+ Notification(GROUP_ID, title, content, NotificationType.INFORMATION).notify(project)
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt
new file mode 100644
index 000000000..220dd71d7
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt
@@ -0,0 +1,102 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.application.PathManager
+import com.intellij.openapi.components.PersistentStateComponent
+import com.intellij.openapi.components.State
+import com.intellij.openapi.components.Storage
+import java.nio.file.Path
+import kotlin.io.path.Path
+
+@State(name = "MinecraftAnnotaterSettings", storages = [Storage("minecraft_dev.xml")])
+class AnnotaterSettings : PersistentStateComponent {
+
+ data class State(
+ var enabled: Boolean = false,
+ var showAnnotaterJavadocs: Boolean = true,
+ var checkoutMode: CheckoutMode = CheckoutMode.MANAGED,
+ var managedRoot: String = "",
+ var customDirectory: String = "",
+ )
+
+ enum class CheckoutMode {
+ /**
+ * The plugin clones annotater itself and keeps one git worktree per Minecraft version.
+ */
+ MANAGED,
+
+ /**
+ * The user points the plugin at an existing annotater checkout, which the plugin never runs git in.
+ */
+ CUSTOM,
+ }
+
+ private var state = State()
+
+ override fun getState() = state
+
+ override fun loadState(state: State) {
+ this.state = state
+ }
+
+ var enabled: Boolean
+ get() = state.enabled
+ set(enabled) {
+ state.enabled = enabled
+ }
+
+ var showAnnotaterJavadocs: Boolean
+ get() = state.showAnnotaterJavadocs
+ set(showAnnotaterJavadocs) {
+ state.showAnnotaterJavadocs = showAnnotaterJavadocs
+ }
+
+ var checkoutMode: CheckoutMode
+ get() = state.checkoutMode
+ set(checkoutMode) {
+ state.checkoutMode = checkoutMode
+ }
+
+ var managedRoot: String
+ get() = state.managedRoot
+ set(managedRoot) {
+ state.managedRoot = managedRoot
+ }
+
+ var customDirectory: String
+ get() = state.customDirectory
+ set(customDirectory) {
+ state.customDirectory = customDirectory
+ }
+
+ val effectiveManagedRoot: Path
+ get() = state.managedRoot.takeIf { it.isNotBlank() }?.let(::Path) ?: defaultManagedRoot
+
+ companion object {
+ val defaultManagedRoot: Path
+ get() = PathManager.getSystemDir().resolve("minecraft-dev").resolve("annotater")
+
+ val instance: AnnotaterSettings
+ get() = ApplicationManager.getApplication().getService(AnnotaterSettings::class.java)
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt
new file mode 100644
index 000000000..1bec07aba
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt
@@ -0,0 +1,132 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.project.Project
+
+/**
+ * Only available when the Git plugin is installed.
+ */
+interface AnnotaterVcs {
+ /**
+ * Clones annotater if needed, fetches it, and creates or updates the worktree for [version], in the background
+ * with a notification when done, or with a modal progress dialog if [modal].
+ */
+ fun cloneOrUpdate(
+ project: Project?,
+ version: String,
+ modal: Boolean = false,
+ onFinished: (Result) -> Unit = {},
+ )
+
+ /**
+ * Makes sure the managed [checkout] is on a contribution branch rather than the upstream version branch, so that
+ * edits can later be pushed and turned into a pull request. Must not be called on the EDT.
+ */
+ fun prepareForEditing(project: Project?, checkout: AnnotaterCheckout)
+
+ /**
+ * The contribution [checkout] has checked out, or null if HEAD is detached or this isn't a git repository. This is
+ * cheap enough to call from any thread.
+ */
+ fun currentContribution(checkout: AnnotaterCheckout): AnnotaterContribution?
+
+ /**
+ * Newest first. Must not be called on the EDT.
+ */
+ fun contributions(project: Project?, checkout: AnnotaterCheckout): List
+
+ /**
+ * Must not be called on the EDT.
+ */
+ fun uncommittedMappingFiles(project: Project?, checkout: AnnotaterCheckout): List
+
+ /**
+ * Commits any changes in the managed [checkout] with [commitMessage], then checks out the contribution [branch],
+ * or the latest upstream version branch to start a new contribution if it's null. Must not be called on the EDT.
+ */
+ fun switchContribution(
+ project: Project?,
+ checkout: AnnotaterCheckout,
+ branch: String?,
+ commitMessage: String?,
+ indicator: ProgressIndicator,
+ )
+
+ /**
+ * Deletes the contribution [branch] from the managed [checkout]. Only the local branch is deleted, not any branch
+ * pushed for it or its pull request. If [branch] is checked out, its uncommitted changes are discarded and the
+ * upstream version branch is checked out instead. Must not be called on the EDT.
+ */
+ fun deleteContribution(
+ project: Project?,
+ checkout: AnnotaterCheckout,
+ branch: String,
+ indicator: ProgressIndicator,
+ )
+
+ companion object {
+ val instance: AnnotaterVcs?
+ get() = ApplicationManager.getApplication().getService(AnnotaterVcs::class.java)
+ }
+}
+
+/**
+ * @property title the title of the contribution's pull request, or its latest commit message if it doesn't have one
+ * yet, or null if nothing has been committed to it
+ */
+data class AnnotaterContribution(val branch: String, val title: String?) {
+ val displayName: String
+ get() = title ?: branch
+
+ companion object {
+ fun defaultTitle(files: List): String {
+ val classes = files.map { it.substringAfterLast('/').removeSuffix(".mapping") }.distinct()
+ return when {
+ classes.isEmpty() -> ""
+ classes.size <= 3 -> "Document ${classes.joinToString(", ")}"
+ else -> "Document ${classes.take(3).joinToString(", ")} and ${classes.size - 3} more"
+ }
+ }
+ }
+}
+
+/**
+ * Only available when the GitHub plugin is installed.
+ */
+interface AnnotaterPullRequests {
+ enum class Action { CREATE, UPDATE }
+
+ /**
+ * Whether a pull request can be created or updated with the changes in [checkout], or null if there's nothing to
+ * submit. This is cheap enough to call from any thread, so it may be null until the changes have been looked up.
+ */
+ fun availableAction(checkout: AnnotaterCheckout): Action?
+
+ fun submit(project: Project, checkout: AnnotaterCheckout)
+
+ companion object {
+ val instance: AnnotaterPullRequests?
+ get() = ApplicationManager.getApplication().getService(AnnotaterPullRequests::class.java)
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt b/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt
new file mode 100644
index 000000000..f95662076
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt
@@ -0,0 +1,566 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.git
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterContribution
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterListener
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.Task
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.project.ProjectManager
+import com.intellij.openapi.vcs.VcsException
+import com.intellij.openapi.vfs.LocalFileSystem
+import com.intellij.ui.EditorNotifications
+import com.intellij.util.concurrency.AppExecutorUtil
+import git4idea.commands.Git
+import git4idea.commands.GitCommand
+import git4idea.commands.GitLineHandler
+import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.io.path.exists
+import kotlin.io.path.isDirectory
+import kotlin.io.path.readText
+
+class AnnotaterGitVcs : AnnotaterVcs {
+ private val lock = Any()
+
+ /**
+ * Pull request titles of contribution branches, by branch name.
+ */
+ private val titles = ConcurrentHashMap()
+
+ /**
+ * [AnnotaterContribution.title]s of contribution branches, by branch name, so that the editor banner can show them
+ * without running git.
+ */
+ private val contributionTitles = ConcurrentHashMap()
+
+ /**
+ * The branches whose [contributionTitles] have been looked up at least once.
+ */
+ private val titledBranches = ConcurrentHashMap.newKeySet()
+
+ /**
+ * Cached so that the editor banner can show it without running git.
+ */
+ private val pushStatuses = ConcurrentHashMap()
+
+ /**
+ * The checkouts whose [pushStatuses] have been requested, to refresh them when they change.
+ */
+ private val pushStatusCheckouts = ConcurrentHashMap()
+
+ private val pendingPushStatuses = ConcurrentHashMap.newKeySet()
+
+ private val pushStatusExecutor =
+ AppExecutorUtil.createBoundedApplicationPoolExecutor("Annotater push status", 1)
+
+ init {
+ ApplicationManager.getApplication().messageBus.connect().subscribe(
+ AnnotaterListener.TOPIC,
+ object : AnnotaterListener {
+ override fun checkoutsChanged() {
+ pushStatusCheckouts.values.forEach(::refreshPushStatus)
+ }
+
+ override fun javadocsChanged(checkoutRoot: Path, outerClass: String) {
+ pushStatusCheckouts[checkoutRoot]?.let(::refreshPushStatus)
+ }
+ },
+ )
+ }
+
+ override fun cloneOrUpdate(
+ project: Project?,
+ version: String,
+ modal: Boolean,
+ onFinished: (Result) -> Unit,
+ ) {
+ val title = MCDevBundle("fabric.annotater.cloning", version)
+ var checkout: AnnotaterCheckout? = null
+
+ fun doRun(indicator: ProgressIndicator) {
+ checkout = cloneOrUpdateNow(project, version, indicator)
+ }
+
+ fun doSuccess() {
+ val checkout = checkout ?: return
+ ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged()
+ if (!modal) {
+ AnnotaterNotifications.notifyInfo(
+ project,
+ MCDevBundle("fabric.annotater.cloned", version),
+ MCDevBundle("fabric.annotater.cloned.content", checkout.root),
+ )
+ }
+ onFinished(Result.success(checkout))
+ }
+
+ fun doFail(error: Throwable) {
+ if (!modal) {
+ AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.clone_failed"), error)
+ }
+ onFinished(Result.failure(error))
+ }
+
+ val task = if (modal) {
+ object : Task.Modal(project, title, true) {
+ override fun run(indicator: ProgressIndicator) = doRun(indicator)
+ override fun onSuccess() = doSuccess()
+ override fun onThrowable(error: Throwable) = doFail(error)
+ }
+ } else {
+ object : Task.Backgroundable(project, title, true) {
+ override fun run(indicator: ProgressIndicator) = doRun(indicator)
+ override fun onSuccess() = doSuccess()
+ override fun onThrowable(error: Throwable) = doFail(error)
+ }
+ }
+ task.queue()
+ }
+
+ @Throws(VcsException::class)
+ private fun cloneOrUpdateNow(project: Project?, version: String, indicator: ProgressIndicator): AnnotaterCheckout {
+ val gitProject = project ?: ProjectManager.getInstance().defaultProject
+ synchronized(lock) {
+ val root = AnnotaterSettings.instance.effectiveManagedRoot
+ val repo = root.resolve(REPO_DIR)
+ if (!repo.resolve(".git").exists()) {
+ indicator.text = MCDevBundle("fabric.annotater.cloning.clone")
+ Files.createDirectories(root)
+ val result = Git.getInstance().clone(gitProject, root, upstreamUrl, REPO_DIR)
+ if (!result.success()) {
+ throw VcsException(result.errorOutputAsJoinedString)
+ }
+ } else {
+ indicator.text = MCDevBundle("fabric.annotater.cloning.fetch")
+ run(gitProject, repo, GitCommand.FETCH, "--prune", UPSTREAM_REMOTE, url = upstreamUrl)
+ }
+
+ val remoteBranch = "$UPSTREAM_REMOTE/$version"
+ val hasBranch = runCatching {
+ run(gitProject, repo, GitCommand.REV_PARSE, "--verify", "--quiet", "refs/remotes/$remoteBranch")
+ }.isSuccess
+ if (!hasBranch) {
+ throw VcsException(MCDevBundle("fabric.annotater.no_branch", version))
+ }
+
+ val worktree = AnnotaterCheckoutService.instance.managedWorktree(version)
+ if (!worktree.exists()) {
+ indicator.text = MCDevBundle("fabric.annotater.cloning.worktree")
+ run(gitProject, repo, GitCommand.WORKTREE, "prune")
+ run(gitProject, repo, GitCommand.WORKTREE, "add", "--detach", worktree.toString(), remoteBranch)
+ } else if (currentBranch(gitProject, worktree) == null && isClean(gitProject, worktree)) {
+ // Not contributing yet, so follow upstream
+ run(gitProject, worktree, GitCommand.CHECKOUT, "--detach", remoteBranch)
+ }
+
+ LocalFileSystem.getInstance().refreshNioFiles(listOf(worktree), true, true, null)
+ ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged()
+ return AnnotaterCheckoutService.instance.checkoutForVersion(version)
+ ?: throw VcsException(MCDevBundle("fabric.annotater.invalid_after_clone", worktree))
+ }
+ }
+
+ override fun prepareForEditing(project: Project?, checkout: AnnotaterCheckout) {
+ if (!checkout.managed) {
+ return
+ }
+ val gitProject = project ?: ProjectManager.getInstance().defaultProject
+ synchronized(lock) {
+ if (currentBranch(gitProject, checkout.root) != null) {
+ refreshCurrentTitle(gitProject, checkout)
+ return
+ }
+ val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))
+ run(gitProject, checkout.root, GitCommand.CHECKOUT, "-b", "${branchPrefix(checkout)}$timestamp")
+ }
+ ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged()
+ }
+
+ override fun currentContribution(checkout: AnnotaterCheckout): AnnotaterContribution? {
+ val branch = readHeadBranch(checkout.root) ?: return null
+ if (checkout.managed && titledBranches.add(branch)) {
+ // Otherwise the banner shows the branch name until the contributions are listed or a Javadoc is edited
+ ApplicationManager.getApplication().executeOnPooledThread {
+ synchronized(lock) {
+ runCatching { refreshCurrentTitle(ProjectManager.getInstance().defaultProject, checkout) }
+ }
+ }
+ }
+ return AnnotaterContribution(branch, contributionTitles[branch])
+ }
+
+ /**
+ * Reads the worktree's `HEAD` file directly, to avoid running git.
+ */
+ private fun readHeadBranch(root: Path): String? {
+ return try {
+ val dotGit = root.resolve(".git")
+ val gitDir = if (dotGit.isDirectory()) {
+ dotGit
+ } else {
+ val gitDirPath = dotGit.readText().trim().removePrefix("gitdir:").trim()
+ root.resolve(gitDirPath)
+ }
+ // A detached HEAD contains a commit hash instead
+ val head = gitDir.resolve("HEAD").readText().trim()
+ head.takeIf { it.startsWith(HEAD_REF_PREFIX) }?.removePrefix(HEAD_REF_PREFIX)
+ } catch (e: IOException) {
+ null
+ }
+ }
+
+ override fun contributions(project: Project?, checkout: AnnotaterCheckout): List {
+ val gitProject = project ?: ProjectManager.getInstance().defaultProject
+ loadTitles(gitProject, checkout)
+ val branches = run(
+ gitProject,
+ checkout.root,
+ GitCommand.FOR_EACH_REF,
+ "--sort=-refname",
+ "--format=%(refname:short)",
+ "refs/heads/${branchPrefix(checkout)}",
+ ).map { it.trim() }.filter { it.isNotEmpty() }
+ return branches.map { AnnotaterContribution(it, contributionTitle(gitProject, checkout, it)) }
+ }
+
+ /**
+ * The pull request title of the contribution [branch], or its latest commit message if it doesn't have one yet.
+ */
+ private fun contributionTitle(project: Project, checkout: AnnotaterCheckout, branch: String): String? {
+ val title = titles[branch] ?: run(
+ project,
+ checkout.root,
+ GitCommand.LOG,
+ "-1",
+ "--format=%s",
+ "$UPSTREAM_REMOTE/${checkout.version}..$branch",
+ "--",
+ ).firstOrNull()?.takeIf { it.isNotBlank() }
+ if (title != null) {
+ contributionTitles[branch] = title
+ } else {
+ contributionTitles.remove(branch)
+ }
+ return title
+ }
+
+ private fun refreshCurrentTitle(project: Project, checkout: AnnotaterCheckout) {
+ val branch = currentBranch(project, checkout.root) ?: return
+ loadTitles(project, checkout)
+ val old = contributionTitles[branch]
+ if (runCatching { contributionTitle(project, checkout, branch) }.getOrNull() != old) {
+ EditorNotifications.updateAll()
+ }
+ }
+
+ fun pullRequestTitle(project: Project, checkout: AnnotaterCheckout, branch: String): String? {
+ loadTitles(project, checkout)
+ return titles[branch]
+ }
+
+ /**
+ * Loads the pull request titles stored as branch descriptions by [setContributionTitle].
+ */
+ private fun loadTitles(project: Project, checkout: AnnotaterCheckout) {
+ val output = runCatching {
+ run(project, checkout.root, GitCommand.CONFIG, "--get-regexp", "^branch\\..*\\.description$")
+ }.getOrDefault(emptyList())
+ for (line in output) {
+ val key = line.substringBefore(' ')
+ val value = line.substringAfter(' ', "").trim()
+ if (value.isNotEmpty()) {
+ titles[key.removePrefix("branch.").removeSuffix(".description")] = value
+ }
+ }
+ }
+
+ /**
+ * Stored as the branch's git branch description.
+ */
+ fun setContributionTitle(project: Project, checkout: AnnotaterCheckout, branch: String, title: String) {
+ run(project, checkout.root, GitCommand.CONFIG, "branch.$branch.description", title)
+ titles[branch] = title
+ contributionTitles[branch] = title
+ EditorNotifications.updateAll()
+ }
+
+ /**
+ * Whether the contribution checked out in a managed checkout has a pull request, and whether it has changes that
+ * haven't been pushed to it, or to a new pull request if it doesn't have one.
+ */
+ data class PushStatus(val pullRequestUrl: String?, val hasUnpushedChanges: Boolean)
+
+ /**
+ * The [PushStatus] of the managed [checkout], or null if it isn't known yet, in which case it's looked up in the
+ * background and the editor banners are updated. This is cheap enough to call from any thread.
+ */
+ fun pushStatus(checkout: AnnotaterCheckout): PushStatus? {
+ if (!checkout.managed) {
+ return null
+ }
+ if (pushStatusCheckouts.put(checkout.root, checkout) == null) {
+ refreshPushStatus(checkout)
+ }
+ return pushStatuses[checkout.root]
+ }
+
+ private fun refreshPushStatus(checkout: AnnotaterCheckout) {
+ val root = checkout.root
+ if (!pendingPushStatuses.add(root)) {
+ return
+ }
+ pushStatusExecutor.execute {
+ pendingPushStatuses.remove(root)
+ val status = runCatching { computePushStatus(ProjectManager.getInstance().defaultProject, checkout) }
+ .getOrNull()
+ val old = if (status == null) pushStatuses.remove(root) else pushStatuses.put(root, status)
+ if (old != status) {
+ EditorNotifications.updateAll()
+ }
+ }
+ }
+
+ private fun computePushStatus(project: Project, checkout: AnnotaterCheckout): PushStatus {
+ val branch = currentBranch(project, checkout.root) ?: return PushStatus(null, false)
+ val pullRequestUrl = getBranchConfig(project, checkout, branch, PULL_REQUEST_KEY)
+ val pushedCommit = getBranchConfig(project, checkout, branch, PUSHED_COMMIT_KEY)
+ val hasUnpushedChanges = when {
+ uncommittedMappingFiles(project, checkout).isNotEmpty() -> true
+ pullRequestUrl != null && pushedCommit != null -> headCommit(project, checkout) != pushedCommit
+ else -> commitsAheadOfUpstream(project, checkout) > 0
+ }
+ return PushStatus(pullRequestUrl, hasUnpushedChanges)
+ }
+
+ fun pullRequestUrl(project: Project, checkout: AnnotaterCheckout, branch: String): String? =
+ getBranchConfig(project, checkout, branch, PULL_REQUEST_KEY)
+
+ fun setPullRequest(project: Project, checkout: AnnotaterCheckout, branch: String, url: String) {
+ run(project, checkout.root, GitCommand.CONFIG, "branch.$branch.$PULL_REQUEST_KEY", url)
+ run(
+ project,
+ checkout.root,
+ GitCommand.CONFIG,
+ "branch.$branch.$PUSHED_COMMIT_KEY",
+ headCommit(project, checkout),
+ )
+ refreshPushStatus(checkout)
+ }
+
+ private fun getBranchConfig(project: Project, checkout: AnnotaterCheckout, branch: String, key: String): String? {
+ // git config exits with 1 when the key isn't set
+ return runCatching { run(project, checkout.root, GitCommand.CONFIG, "--get", "branch.$branch.$key") }
+ .getOrNull()?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() }
+ }
+
+ private fun headCommit(project: Project, checkout: AnnotaterCheckout): String =
+ run(project, checkout.root, GitCommand.REV_PARSE, "HEAD").first().trim()
+
+ override fun uncommittedMappingFiles(project: Project?, checkout: AnnotaterCheckout): List {
+ val output = run(
+ project ?: ProjectManager.getInstance().defaultProject,
+ checkout.root,
+ GitCommand.STATUS,
+ "--porcelain",
+ "--untracked-files=all",
+ "--",
+ checkout.config.mappings,
+ )
+ return output.mapNotNull { line -> line.takeIf { it.length > 3 }?.substring(3) }
+ }
+
+ /**
+ * The mapping files changed on the current branch since it diverged from the upstream version branch, whether
+ * committed or not.
+ */
+ fun mappingFilesChangedSinceUpstream(project: Project, checkout: AnnotaterCheckout): List {
+ val base = run(project, checkout.root, GitCommand.MERGE_BASE, "$UPSTREAM_REMOTE/${checkout.version}", "HEAD")
+ .first().trim()
+ val committed =
+ run(project, checkout.root, GitCommand.DIFF, "--name-only", base, "--", checkout.config.mappings)
+ return (committed + uncommittedMappingFiles(project, checkout)).map { it.trim() }.filter { it.isNotEmpty() }
+ .distinct()
+ }
+
+ fun commitMappings(project: Project, checkout: AnnotaterCheckout, message: String) {
+ synchronized(lock) {
+ run(project, checkout.root, GitCommand.ADD, "--all", "--", checkout.config.mappings)
+ run(project, checkout.root, GitCommand.COMMIT, "-m", message)
+ }
+ }
+
+ fun commitsAheadOfUpstream(project: Project, checkout: AnnotaterCheckout): Int {
+ val output = run(
+ project,
+ checkout.root,
+ GitCommand.REV_LIST,
+ "--count",
+ "$UPSTREAM_REMOTE/${checkout.version}..HEAD",
+ )
+ return output.firstOrNull()?.trim()?.toIntOrNull() ?: 0
+ }
+
+ fun push(project: Project, checkout: AnnotaterCheckout, url: String, branch: String) {
+ run(project, checkout.root, GitCommand.PUSH, url, "HEAD:refs/heads/$branch", url = url)
+ }
+
+ override fun switchContribution(
+ project: Project?,
+ checkout: AnnotaterCheckout,
+ branch: String?,
+ commitMessage: String?,
+ indicator: ProgressIndicator,
+ ) {
+ val gitProject = project ?: ProjectManager.getInstance().defaultProject
+ synchronized(lock) {
+ if (commitMessage != null && uncommittedMappingFiles(gitProject, checkout).isNotEmpty()) {
+ if (currentBranch(gitProject, checkout.root) == null) {
+ throw VcsException(MCDevBundle("fabric.annotater.uncommitted_changes"))
+ }
+ indicator.text = MCDevBundle("fabric.annotater.pr.committing")
+ commitMappings(gitProject, checkout, commitMessage)
+ }
+ if (!isClean(gitProject, checkout.root)) {
+ throw VcsException(MCDevBundle("fabric.annotater.uncommitted_changes"))
+ }
+ if (branch == null) {
+ indicator.text = MCDevBundle("fabric.annotater.cloning.fetch")
+ val repo = AnnotaterSettings.instance.effectiveManagedRoot.resolve(REPO_DIR)
+ run(gitProject, repo, GitCommand.FETCH, "--prune", UPSTREAM_REMOTE, url = upstreamUrl)
+ run(gitProject, checkout.root, GitCommand.CHECKOUT, "--detach", "$UPSTREAM_REMOTE/${checkout.version}")
+ } else {
+ run(gitProject, checkout.root, GitCommand.CHECKOUT, branch)
+ }
+ refreshCurrentTitle(gitProject, checkout)
+ }
+ LocalFileSystem.getInstance().refreshNioFiles(listOf(checkout.root), true, true, null)
+ ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged()
+ }
+
+ override fun deleteContribution(
+ project: Project?,
+ checkout: AnnotaterCheckout,
+ branch: String,
+ indicator: ProgressIndicator,
+ ) {
+ val gitProject = project ?: ProjectManager.getInstance().defaultProject
+ synchronized(lock) {
+ if (currentBranch(gitProject, checkout.root) == branch) {
+ // Discard the contribution's uncommitted changes, including new mapping files
+ val untracked = run(
+ gitProject,
+ checkout.root,
+ GitCommand.STATUS,
+ "--porcelain",
+ "--untracked-files=all",
+ "--",
+ checkout.config.mappings,
+ ).filter { it.startsWith("?? ") }.map { checkout.root.resolve(it.substring(3)) }
+ run(
+ gitProject,
+ checkout.root,
+ GitCommand.CHECKOUT,
+ "--force",
+ "--detach",
+ "$UPSTREAM_REMOTE/${checkout.version}",
+ )
+ untracked.forEach { Files.deleteIfExists(it) }
+ }
+ // Also deletes the branch's configuration, such as its pull request title
+ run(gitProject, checkout.root, GitCommand.BRANCH, "-D", branch)
+ titles.remove(branch)
+ contributionTitles.remove(branch)
+ titledBranches.remove(branch)
+ }
+ LocalFileSystem.getInstance().refreshNioFiles(listOf(checkout.root), true, true, null)
+ ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged()
+ }
+
+ private fun branchPrefix(checkout: AnnotaterCheckout) = "mcdev/${checkout.version}/"
+
+ private fun currentBranch(project: Project, dir: Path): String? {
+ val output = runCatching { run(project, dir, GitCommand.REV_PARSE, "--abbrev-ref", "HEAD") }.getOrNull()
+ return output?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() && it != "HEAD" }
+ }
+
+ private fun isClean(project: Project, dir: Path): Boolean {
+ return run(project, dir, GitCommand.STATUS, "--porcelain").all { it.isBlank() }
+ }
+
+ @Throws(VcsException::class)
+ private fun run(
+ project: Project,
+ dir: Path,
+ command: GitCommand,
+ vararg parameters: String,
+ url: String? = null,
+ ): List {
+ val handler = GitLineHandler(project, dir, command)
+ handler.addParameters(*parameters)
+ if (url != null) {
+ handler.setUrl(url)
+ }
+ handler.setSilent(true)
+ val result = Git.getInstance().runCommand(handler)
+ if (!result.success()) {
+ throw VcsException(
+ result.errorOutputAsJoinedString.ifBlank { "git ${command.name()} failed with ${result.exitCode}" },
+ )
+ }
+ return result.output
+ }
+
+ companion object {
+ private const val REPO_DIR = ".repo"
+ private const val HEAD_REF_PREFIX = "ref: refs/heads/"
+ private const val UPSTREAM_REMOTE = "origin"
+
+ /**
+ * The git config keys of contribution branches, under `branch.`, storing the URL of their pull request
+ * and the commit last pushed to it.
+ */
+ private const val PULL_REQUEST_KEY = "mcdevPullRequest"
+ private const val PUSHED_COMMIT_KEY = "mcdevPushedCommit"
+
+ /**
+ * The GitHub repository annotater is cloned from and pull requests are opened against, as `owner/name`. Can
+ * be overridden to test against a fork without opening real pull requests.
+ */
+ val upstreamRepository: String = System.getProperty("mcdev.annotater.upstream", "FabricMC/annotater")
+ private val upstreamUrl = "https://github.com/$upstreamRepository.git"
+
+ val instance: AnnotaterGitVcs?
+ get() = AnnotaterVcs.instance as? AnnotaterGitVcs
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt
new file mode 100644
index 000000000..7d8a166e2
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt
@@ -0,0 +1,120 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+
+package com.demonwav.mcdev.platform.fabric.annotater.github
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout
+import com.intellij.execution.configurations.GeneralCommandLine
+import com.intellij.execution.process.CapturingProcessHandler
+import com.intellij.execution.process.ProcessOutput
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.ProgressManager
+import com.intellij.openapi.progress.Task
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.projectRoots.JavaSdkType
+import com.intellij.openapi.roots.ProjectRootManager
+import com.intellij.openapi.ui.DialogWrapper
+import com.intellij.openapi.util.SystemInfo
+import com.intellij.ui.components.JBScrollPane
+import com.intellij.ui.components.JBTextArea
+import com.intellij.ui.dsl.builder.AlignX
+import com.intellij.ui.dsl.builder.AlignY
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.util.ui.JBUI
+import java.awt.Dimension
+import java.awt.Font
+
+/**
+ * Runs annotater's own checks with `gradlew check` before a pull request is submitted.
+ */
+object AnnotaterBuildCheck {
+ /**
+ * Runs the checks in [checkout] with a modal progress dialog. If they fail, the user is shown the output and can
+ * choose to submit anyway.
+ *
+ * @return whether to continue submitting
+ */
+ fun run(project: Project, checkout: AnnotaterCheckout): Boolean {
+ var result: ProcessOutput? = null
+ var error: Throwable? = null
+ // Modal tasks finish before this returns
+ ProgressManager.getInstance().run(
+ object : Task.Modal(project, MCDevBundle("fabric.annotater.pr.check_build.progress"), true) {
+ override fun run(indicator: ProgressIndicator) {
+ indicator.isIndeterminate = true
+ result = CapturingProcessHandler(commandLine(project, checkout))
+ .runProcessWithProgressIndicator(indicator)
+ }
+
+ override fun onThrowable(e: Throwable) {
+ error = e
+ }
+ },
+ )
+ error?.let { return FailedDialog(project, it.message ?: it.toString()).showAndGet() }
+ val output = result?.takeUnless { it.isCancelled } ?: return false
+ if (output.exitCode == 0 && !output.isTimeout) {
+ return true
+ }
+ val log = (output.stdout + "\n" + output.stderr).trim()
+ return FailedDialog(project, log).showAndGet()
+ }
+
+ private fun commandLine(project: Project, checkout: AnnotaterCheckout): GeneralCommandLine {
+ val wrapper = checkout.root.resolve(if (SystemInfo.isWindows) "gradlew.bat" else "gradlew")
+ val commandLine = GeneralCommandLine(wrapper.toString(), "check", "--console=plain")
+ .withWorkingDirectory(checkout.root)
+ .withCharset(Charsets.UTF_8)
+ // Minecraft projects need a recent Java version, which the environment's JAVA_HOME may not be
+ val sdk = ProjectRootManager.getInstance(project).projectSdk?.takeIf { it.sdkType is JavaSdkType }
+ sdk?.homePath?.let { commandLine.withEnvironment("JAVA_HOME", it) }
+ return commandLine
+ }
+
+ private class FailedDialog(project: Project, private val log: String) : DialogWrapper(project) {
+ init {
+ title = MCDevBundle("fabric.annotater.pr.check_build.failed")
+ setOKButtonText(MCDevBundle("fabric.annotater.pr.check_build.submit_anyway"))
+ okAction.putValue(DEFAULT_ACTION, null)
+ cancelAction.putValue(DEFAULT_ACTION, true)
+ init()
+ }
+
+ override fun createCenterPanel() = panel {
+ row {
+ label(MCDevBundle("fabric.annotater.pr.check_build.failed.message"))
+ }
+ row {
+ val textArea = JBTextArea(log).apply {
+ isEditable = false
+ font = JBUI.Fonts.create(Font.MONOSPACED, font.size)
+ // The cause of a failure is usually at the end
+ caretPosition = document.length
+ }
+ cell(JBScrollPane(textArea))
+ .align(AlignX.FILL)
+ .align(AlignY.FILL)
+ .applyToComponent { preferredSize = Dimension(JBUI.scale(800), JBUI.scale(400)) }
+ }.resizableRow()
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt
new file mode 100644
index 000000000..a2d0a3e2c
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt
@@ -0,0 +1,349 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.github
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterPullRequests
+import com.demonwav.mcdev.platform.fabric.annotater.git.AnnotaterGitVcs
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocContributions
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import com.intellij.ide.BrowserUtil
+import com.intellij.notification.Notification
+import com.intellij.notification.NotificationAction
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.Task
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vcs.VcsException
+import com.intellij.util.io.HttpRequests
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URLEncoder
+import org.jetbrains.plugins.github.api.GithubServerPath
+import org.jetbrains.plugins.github.authentication.GHAccountsUtil
+import org.jetbrains.plugins.github.authentication.accounts.GithubAccount
+import org.jetbrains.plugins.github.util.GHCompatibilityUtil
+
+class AnnotaterGitHubPullRequests : AnnotaterPullRequests {
+ override fun availableAction(checkout: AnnotaterCheckout): AnnotaterPullRequests.Action? {
+ val status = AnnotaterGitVcs.instance?.pushStatus(checkout)?.takeIf { it.hasUnpushedChanges } ?: return null
+ return if (status.pullRequestUrl != null) {
+ AnnotaterPullRequests.Action.UPDATE
+ } else {
+ AnnotaterPullRequests.Action.CREATE
+ }
+ }
+
+ override fun submit(project: Project, checkout: AnnotaterCheckout) {
+ val git = AnnotaterGitVcs.instance ?: return
+
+ object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.pr.checking"), true) {
+ private var changes = emptyList()
+ private var uncommittedChanges = emptyList()
+ private var commitsAhead = 0
+ private var branch: String? = null
+ private var existingTitle: String? = null
+ private var existingUrl: String? = null
+
+ override fun run(indicator: ProgressIndicator) {
+ changes = git.mappingFilesChangedSinceUpstream(project, checkout)
+ uncommittedChanges = git.uncommittedMappingFiles(project, checkout)
+ commitsAhead = git.commitsAheadOfUpstream(project, checkout)
+ branch = git.currentContribution(checkout)?.branch
+ existingTitle = branch?.let { git.pullRequestTitle(project, checkout, it) }
+ existingUrl = branch?.let { git.pullRequestUrl(project, checkout, it) }
+ }
+
+ override fun onSuccess() {
+ val branch = branch
+ if (changes.isEmpty() && uncommittedChanges.isEmpty() && commitsAhead == 0 || branch == null) {
+ AnnotaterNotifications.notifyInfo(
+ project,
+ MCDevBundle("fabric.annotater.pr.nothing"),
+ MCDevBundle("fabric.annotater.pr.nothing.content", checkout.version),
+ )
+ return
+ }
+ showDialog(project, checkout, git, branch, changes, uncommittedChanges, existingTitle, existingUrl)
+ }
+
+ override fun onThrowable(error: Throwable) {
+ AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.pr.failed"), error)
+ }
+ }.queue()
+ }
+
+ private fun showDialog(
+ project: Project,
+ checkout: AnnotaterCheckout,
+ git: AnnotaterGitVcs,
+ branch: String,
+ changes: List,
+ uncommittedChanges: List,
+ existingTitle: String?,
+ existingUrl: String?,
+ ) {
+ if (GHAccountsUtil.accounts.isEmpty()) {
+ GHAccountsUtil.requestNewAccount(GithubServerPath.DEFAULT_SERVER, null, project) ?: return
+ }
+ val accounts = GHAccountsUtil.accounts.filter { it.server.isGithubDotCom }
+ if (accounts.isEmpty()) {
+ AnnotaterNotifications.notifyInfo(
+ project,
+ MCDevBundle("fabric.annotater.pr.failed"),
+ MCDevBundle("fabric.annotater.pr.no_account"),
+ )
+ return
+ }
+ val defaultAccount = GHAccountsUtil.getSingleOrDefaultAccount(project)?.takeIf { it in accounts }
+
+ val dialog = AnnotaterPullRequestDialog(
+ project,
+ accounts,
+ defaultAccount,
+ changes,
+ uncommittedChanges,
+ existingTitle,
+ update = existingUrl != null,
+ )
+ if (!dialog.showAndGet()) {
+ return
+ }
+ val account = dialog.account ?: return
+ if (dialog.checkBuild && !AnnotaterBuildCheck.run(project, checkout)) {
+ return
+ }
+ val token = GHCompatibilityUtil.getOrRequestToken(account, project) ?: return
+
+ val request = PullRequest(dialog.prTitle, dialog.prDescription, dialog.commitMessage, branch, existingUrl)
+ val title = if (existingUrl != null) "fabric.annotater.pr.updating" else "fabric.annotater.pr.creating"
+ object : Task.Backgroundable(project, MCDevBundle(title), false) {
+ private var url: String? = null
+
+ override fun run(indicator: ProgressIndicator) {
+ val url =
+ submit(project, checkout, git, account, token, request, uncommittedChanges.isNotEmpty(), indicator)
+ this.url = url
+ git.setPullRequest(project, checkout, request.branch, url)
+ if (existingUrl == null) {
+ // Label the contribution with its pull request, for switching back to it later
+ runCatching { git.setContributionTitle(project, checkout, request.branch, request.title) }
+ }
+ }
+
+ override fun onSuccess() {
+ val url = url ?: return
+ val notificationTitle =
+ if (existingUrl != null) "fabric.annotater.pr.updated" else "fabric.annotater.pr.created"
+ Notification(
+ AnnotaterNotifications.GROUP_ID,
+ MCDevBundle(notificationTitle),
+ url,
+ NotificationType.INFORMATION,
+ ).addAction(
+ NotificationAction.createSimple(MCDevBundle("fabric.annotater.pr.open")) {
+ BrowserUtil.browse(url)
+ },
+ ).addAction(
+ NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.pr.start_new")) {
+ AnnotaterJavadocContributions.switchContribution(project, checkout, null)
+ },
+ ).notify(project)
+ }
+
+ override fun onThrowable(error: Throwable) {
+ AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.pr.failed"), error)
+ }
+ }.queue()
+ }
+
+ /**
+ * @property existingUrl the URL of the pull request to update, or null to create one
+ */
+ private class PullRequest(
+ val title: String,
+ val body: String,
+ val commitMessage: String,
+ val branch: String,
+ val existingUrl: String?,
+ )
+
+ /**
+ * Commits and pushes the changes, and creates the pull request unless it already exists. Returns its URL.
+ */
+ @Throws(IOException::class, VcsException::class)
+ private fun submit(
+ project: Project,
+ checkout: AnnotaterCheckout,
+ git: AnnotaterGitVcs,
+ account: GithubAccount,
+ token: String,
+ request: PullRequest,
+ hasChanges: Boolean,
+ indicator: ProgressIndicator,
+ ): String {
+ val api = GitHubApi(account.server.toApiUrl(), token)
+ val upstream = AnnotaterGitVcs.upstreamRepository
+
+ // Otherwise GitHub only rejects the pull request with "base invalid" after committing, forking and pushing
+ if (request.existingUrl == null && !api.exists("/repos/$upstream/branches/${checkout.version}")) {
+ throw IOException(MCDevBundle("fabric.annotater.pr.no_base_branch", upstream, checkout.version))
+ }
+
+ if (hasChanges) {
+ indicator.text = MCDevBundle("fabric.annotater.pr.committing")
+ git.commitMappings(project, checkout, request.commitMessage)
+ }
+
+ // Returns the existing fork if there is one
+ indicator.text = MCDevBundle("fabric.annotater.pr.forking")
+ val fork = api.post("/repos/$upstream/forks", JsonObject())
+ val forkName = fork.get("full_name").asString
+ val forkOwner = fork.getAsJsonObject("owner").get("login").asString
+ val cloneUrl = fork.get("clone_url").asString
+ waitForFork(api, forkName, indicator)
+
+ indicator.text = MCDevBundle("fabric.annotater.pr.pushing")
+ git.push(project, checkout, cloneUrl, request.branch)
+ if (request.existingUrl != null) {
+ return request.existingUrl
+ }
+
+ indicator.text = MCDevBundle("fabric.annotater.pr.opening")
+ val body = JsonObject().apply {
+ addProperty("title", request.title)
+ addProperty("body", request.body)
+ addProperty("head", "$forkOwner:${request.branch}")
+ addProperty("base", checkout.version)
+ addProperty("maintainer_can_modify", true)
+ }
+ return try {
+ api.post("/repos/$upstream/pulls", body).get("html_url").asString
+ } catch (e: GitHubApiException) {
+ // The branch was pushed to an existing pull request, which is now up to date
+ val head = "$forkOwner:${request.branch}"
+ val existing = if (e.statusCode == HTTP_UNPROCESSABLE_ENTITY) {
+ api.get("/repos/$upstream/pulls?state=open&head=${URLEncoder.encode(head, Charsets.UTF_8)}")
+ .asJsonArray.firstOrNull()?.asJsonObject?.get("html_url")?.asString
+ } else {
+ null
+ }
+ existing ?: throw e
+ }
+ }
+
+ /**
+ * Forks are created asynchronously, so wait until GitHub has finished creating it before pushing.
+ */
+ private fun waitForFork(api: GitHubApi, forkName: String, indicator: ProgressIndicator) {
+ repeat(FORK_POLL_ATTEMPTS) {
+ indicator.checkCanceled()
+ if (api.exists("/repos/$forkName")) {
+ return
+ }
+ Thread.sleep(FORK_POLL_DELAY_MS)
+ }
+ }
+
+ private class GitHubApiException(val statusCode: Int, message: String) : IOException(message)
+
+ private class GitHubApi(private val baseUrl: String, private val token: String) {
+ private val gson = Gson()
+
+ fun post(path: String, body: JsonObject): JsonObject {
+ return HttpRequests.post(baseUrl + path, "application/json")
+ .tuner(::authorize)
+ .throwStatusCodeException(false)
+ .connect { request ->
+ request.write(gson.toJson(body))
+ gson.fromJson(readResponse(request), JsonObject::class.java)
+ }
+ }
+
+ fun get(path: String): JsonElement {
+ return HttpRequests.request(baseUrl + path)
+ .tuner(::authorize)
+ .throwStatusCodeException(false)
+ .connect { request -> JsonParser.parseString(readResponse(request)) }
+ }
+
+ fun exists(path: String): Boolean {
+ return try {
+ get(path)
+ true
+ } catch (e: GitHubApiException) {
+ if (e.statusCode == HttpURLConnection.HTTP_NOT_FOUND) false else throw e
+ }
+ }
+
+ /**
+ * Reads the response body, or throws an exception with GitHub's explanation of the error, which
+ * [HttpURLConnection] otherwise hides.
+ */
+ private fun readResponse(request: HttpRequests.Request): String {
+ val connection = request.connection as HttpURLConnection
+ val statusCode = connection.responseCode
+ if (statusCode < HttpURLConnection.HTTP_BAD_REQUEST) {
+ return request.readString()
+ }
+ val error = connection.errorStream?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty()
+ throw GitHubApiException(statusCode, describeError(statusCode, error))
+ }
+
+ private fun describeError(statusCode: Int, body: String): String {
+ val json = runCatching { JsonParser.parseString(body).asJsonObject }.getOrNull()
+ ?: return "GitHub returned HTTP $statusCode: $body"
+ val message = json.get("message")?.takeIf { it.isJsonPrimitive }?.asString ?: "HTTP $statusCode"
+ val details = json.get("errors")?.takeIf { it.isJsonArray }?.asJsonArray?.mapNotNull { error ->
+ when {
+ error.isJsonPrimitive -> error.asString
+ error.isJsonObject -> {
+ val obj = error.asJsonObject
+ obj.get("message")?.asString
+ ?: listOfNotNull(obj.get("field")?.asString, obj.get("code")?.asString)
+ .joinToString(" ")
+ .takeIf { it.isNotEmpty() }
+ }
+
+ else -> null
+ }
+ }.orEmpty()
+ return if (details.isEmpty()) message else "$message: ${details.joinToString("; ")}"
+ }
+
+ private fun authorize(connection: java.net.URLConnection) {
+ connection.setRequestProperty("Authorization", "Bearer $token")
+ connection.setRequestProperty("Accept", "application/vnd.github+json")
+ connection.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
+ }
+ }
+
+ companion object {
+ private const val FORK_POLL_ATTEMPTS = 30
+ private const val FORK_POLL_DELAY_MS = 2000L
+ private const val HTTP_UNPROCESSABLE_ENTITY = 422
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt
new file mode 100644
index 000000000..027f3d75d
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt
@@ -0,0 +1,106 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.github
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterContribution
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.DialogWrapper
+import com.intellij.ui.CollectionComboBoxModel
+import com.intellij.ui.SimpleListCellRenderer
+import com.intellij.ui.dsl.builder.AlignX
+import com.intellij.ui.dsl.builder.bindSelected
+import com.intellij.ui.dsl.builder.bindText
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.builder.rows
+import java.awt.Dimension
+import org.jetbrains.plugins.github.authentication.accounts.GithubAccount
+
+class AnnotaterPullRequestDialog(
+ project: Project,
+ accounts: List,
+ defaultAccount: GithubAccount?,
+ private val changes: List,
+ private val uncommittedChanges: List,
+ existingTitle: String?,
+ private val update: Boolean, // existing PR or update?
+) : DialogWrapper(project) {
+ private val accountModel = CollectionComboBoxModel(accounts, defaultAccount ?: accounts.first())
+
+ var prTitle = existingTitle ?: AnnotaterContribution.defaultTitle(changes)
+ var prDescription = ""
+ var commitMessage = AnnotaterContribution.defaultTitle(uncommittedChanges)
+ var checkBuild = true
+
+ val account: GithubAccount?
+ get() = accountModel.selected
+
+ init {
+ if (update) {
+ title = MCDevBundle("fabric.annotater.update_pull_request")
+ setOKButtonText(MCDevBundle("fabric.annotater.pr.dialog.update"))
+ } else {
+ title = MCDevBundle("fabric.annotater.create_pull_request")
+ setOKButtonText(MCDevBundle("fabric.annotater.pr.dialog.ok"))
+ }
+ init()
+ }
+
+ override fun createCenterPanel() = panel {
+ row(MCDevBundle("fabric.annotater.pr.dialog.account")) {
+ comboBox(accountModel, SimpleListCellRenderer.create("") { it.name })
+ .align(AlignX.FILL)
+ }
+ if (!update) {
+ row(MCDevBundle("fabric.annotater.pr.dialog.title")) {
+ textField().bindText(::prTitle).align(AlignX.FILL).focused()
+ }
+ row(MCDevBundle("fabric.annotater.pr.dialog.description")) {
+ textArea().bindText(::prDescription).rows(6).align(AlignX.FILL)
+ }
+ }
+ if (uncommittedChanges.isNotEmpty()) {
+ row(MCDevBundle("fabric.annotater.pr.dialog.commit_message")) {
+ val field = textField().bindText(::commitMessage).align(AlignX.FILL)
+ if (update) {
+ field.focused()
+ }
+ }
+ }
+ row {
+ checkBox(MCDevBundle("fabric.annotater.pr.dialog.check_build"))
+ .bindSelected(::checkBuild)
+ .comment(MCDevBundle("fabric.annotater.pr.dialog.check_build.comment"))
+ }
+ if (changes.isNotEmpty()) {
+ row {
+ comment(
+ MCDevBundle(
+ "fabric.annotater.pr.dialog.changes",
+ changes.joinToString("
") { it.substringAfterLast('/') },
+ ),
+ )
+ }
+ }
+ }.apply {
+ preferredSize = Dimension(560, preferredSize.height)
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt
new file mode 100644
index 000000000..576f0bc3b
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt
@@ -0,0 +1,46 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.intellij.psi.PsiFile
+import com.intellij.psi.codeStyle.JavaCodeStyleSettings
+import com.intellij.psi.codeStyle.modifier.CodeStyleSettingsModifier
+import com.intellij.psi.codeStyle.modifier.CodeStyleStatusBarUIContributor
+import com.intellij.psi.codeStyle.modifier.TransientCodeStyleSettings
+
+/**
+ * Annotater requires fully qualified class names in Javadoc references, so make completion insert them.
+ */
+class AnnotaterJavadocCodeStyleSettingsModifier : CodeStyleSettingsModifier {
+ override fun modifySettings(settings: TransientCodeStyleSettings, file: PsiFile): Boolean {
+ if (AnnotaterJavadocVirtualFile.of(file) == null) {
+ return false
+ }
+ settings.getCustomSettings(JavaCodeStyleSettings::class.java).CLASS_NAMES_IN_JAVADOC =
+ JavaCodeStyleSettings.FULLY_QUALIFY_NAMES_ALWAYS
+ return true
+ }
+
+ override fun getName() = "Annotater"
+
+ override fun getStatusBarUiContributor(settings: TransientCodeStyleSettings): CodeStyleStatusBarUIContributor? =
+ null
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt
new file mode 100644
index 000000000..e97603969
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt
@@ -0,0 +1,76 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.util.McdevReflection
+import com.demonwav.mcdev.util.McdevReflection.newInstanceUnwrap
+import com.intellij.codeInspection.LocalInspectionTool
+import com.intellij.codeInspection.ProblemHighlightType
+import com.intellij.codeInspection.ProblemsHolder
+import com.intellij.psi.JavaElementVisitor
+import com.intellij.psi.PsiComment
+import com.intellij.psi.PsiElementVisitor
+import com.intellij.psi.javadoc.PsiDocComment
+import com.intellij.psi.util.PsiTreeUtil
+
+/**
+ * Annotater only supports Markdown (`///`) Javadoc comments.
+ */
+class AnnotaterJavadocCommentInspection : LocalInspectionTool() {
+ override fun getStaticDescription() = MCDevBundle("fabric.annotater.inspection.comment.description")
+
+ override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
+ if (AnnotaterJavadocVirtualFile.of(holder.file) == null) {
+ return PsiElementVisitor.EMPTY_VISITOR
+ }
+ val document = holder.file.viewProvider.document
+
+ // Comments in the read-only parts of the copied source file were already there
+ fun isOriginal(comment: PsiComment) = AnnotaterJavadocInlineEditor.isGuarded(document, comment.textRange)
+
+ return object : JavaElementVisitor() {
+ override fun visitDocComment(comment: PsiDocComment) {
+ if (comment.isMarkdownComment || isOriginal(comment)) {
+ return
+ }
+ holder.registerProblem(
+ comment,
+ MCDevBundle("fabric.annotater.javadoc.error.not_markdown"),
+ ProblemHighlightType.GENERIC_ERROR,
+ McdevReflection.CONSTRUCTOR_MARKDOWN_DOCUMENTATION_COMMENTS_MIGRATION_FIX.newInstanceUnwrap(),
+ )
+ }
+
+ override fun visitComment(comment: PsiComment) {
+ // Doc comments are visited by visitDocComment instead
+ if (PsiTreeUtil.getParentOfType(comment, PsiDocComment::class.java) != null || isOriginal(comment)) {
+ return
+ }
+ holder.registerProblem(
+ comment,
+ MCDevBundle("fabric.annotater.javadoc.error.plain_comment"),
+ ProblemHighlightType.GENERIC_ERROR,
+ )
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt
new file mode 100644
index 000000000..0d6f7bb9d
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt
@@ -0,0 +1,272 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterConfigurable
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterContribution
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.actionSystem.DataContext
+import com.intellij.openapi.actionSystem.DefaultActionGroup
+import com.intellij.openapi.actionSystem.Separator
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.Task
+import com.intellij.openapi.project.DumbAwareAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.Messages
+import com.intellij.openapi.ui.popup.JBPopupFactory
+import java.util.concurrent.CompletableFuture
+import javax.swing.JComponent
+
+object AnnotaterJavadocContributions {
+ fun currentLabel(vcs: AnnotaterVcs, checkout: AnnotaterCheckout): String =
+ vcs.currentContribution(checkout)?.displayName ?: MCDevBundle("fabric.annotater.contribution.new")
+
+ fun showPopup(project: Project, checkout: AnnotaterCheckout, component: JComponent) {
+ val vcs = AnnotaterVcs.instance?.takeIf { checkout.managed }
+ if (vcs == null) {
+ showPopup(project, checkout, component, null, null)
+ return
+ }
+ ApplicationManager.getApplication().executeOnPooledThread {
+ val contributions = try {
+ vcs.contributions(project, checkout)
+ } catch (e: Exception) {
+ AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.contribution.failed"), e)
+ return@executeOnPooledThread
+ }
+ ApplicationManager.getApplication().invokeLater {
+ showPopup(project, checkout, component, vcs, contributions)
+ }
+ }
+ }
+
+ private fun showPopup(
+ project: Project,
+ checkout: AnnotaterCheckout,
+ component: JComponent,
+ vcs: AnnotaterVcs?,
+ contributions: List?,
+ ) {
+ if (!component.isShowing) {
+ return
+ }
+ val group = DefaultActionGroup()
+ if (vcs != null && contributions != null) {
+ val current = vcs.currentContribution(checkout)?.branch
+ for (contribution in contributions) {
+ group.add(SwitchAction(project, checkout, contribution, contribution.branch == current))
+ }
+ group.add(
+ DumbAwareAction.create(MCDevBundle("fabric.annotater.contribution.new"), AllIcons.General.Add) {
+ switchContribution(project, checkout, null)
+ },
+ )
+ if (contributions.isNotEmpty()) {
+ val deleteGroup =
+ DefaultActionGroup.createPopupGroup { MCDevBundle("fabric.annotater.contribution.delete") }
+ deleteGroup.templatePresentation.icon = AllIcons.General.Delete
+ for (contribution in contributions) {
+ deleteGroup.add(
+ DumbAwareAction.create(contribution.displayName) {
+ deleteContribution(project, checkout, vcs, contribution, contribution.branch == current)
+ },
+ )
+ }
+ group.add(deleteGroup)
+ }
+ group.add(Separator.getInstance())
+ }
+ group.add(
+ DumbAwareAction.create(MCDevBundle("fabric.annotater.configure"), AllIcons.General.Settings) {
+ AnnotaterConfigurable.Util.show(project)
+ },
+ )
+ JBPopupFactory.getInstance()
+ .createActionGroupPopup(
+ null,
+ group,
+ DataContext.EMPTY_CONTEXT,
+ JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
+ true,
+ )
+ .showUnderneathOf(component)
+ }
+
+ private class SwitchAction(
+ private val project: Project,
+ private val checkout: AnnotaterCheckout,
+ private val contribution: AnnotaterContribution,
+ private val current: Boolean,
+ ) : DumbAwareAction(
+ contribution.displayName,
+ contribution.branch,
+ if (current) AllIcons.Actions.Checked else AllIcons.Vcs.Branch,
+ ) {
+ override fun actionPerformed(e: AnActionEvent) {
+ if (!current) {
+ switchContribution(project, checkout, contribution.branch)
+ }
+ }
+ }
+
+ /**
+ * Switches [checkout] to the contribution [branch], or to a new contribution if it's null. The Javadoc being
+ * edited is saved first, any uncommitted changes are committed to the current contribution, and editing is then
+ * restarted with the Javadoc from the new branch.
+ */
+ fun switchContribution(project: Project, checkout: AnnotaterCheckout, branch: String?) {
+ val vcs = AnnotaterVcs.instance ?: return
+ val saves = listOfNotNull(javadocEditor(project, checkout)?.save())
+ CompletableFuture.allOf(*saves.toTypedArray()).thenAccept {
+ ApplicationManager.getApplication().invokeLater {
+ if (!saves.all { it.join() }) {
+ Messages.showErrorDialog(
+ project,
+ MCDevBundle("fabric.annotater.contribution.save_failed"),
+ MCDevBundle("fabric.annotater.contribution.failed"),
+ )
+ return@invokeLater
+ }
+ askForCommitMessageThenSwitch(project, checkout, vcs, branch)
+ }
+ }
+ }
+
+ private fun askForCommitMessageThenSwitch(
+ project: Project,
+ checkout: AnnotaterCheckout,
+ vcs: AnnotaterVcs,
+ branch: String?,
+ ) {
+ object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.contribution.switching"), true) {
+ private var uncommitted = emptyList()
+
+ override fun run(indicator: ProgressIndicator) {
+ uncommitted = vcs.uncommittedMappingFiles(project, checkout)
+ }
+
+ override fun onSuccess() {
+ if (uncommitted.isEmpty()) {
+ commitAndCheckOut(project, checkout, vcs, branch, null)
+ return
+ }
+ val current = vcs.currentContribution(checkout)?.displayName
+ ?: MCDevBundle("fabric.annotater.contribution.new")
+ val message = Messages.showInputDialog(
+ project,
+ MCDevBundle("fabric.annotater.contribution.commit", current),
+ MCDevBundle("fabric.annotater.contribution.switch"),
+ null,
+ AnnotaterContribution.defaultTitle(uncommitted),
+ null,
+ )?.takeIf { it.isNotBlank() } ?: return
+ commitAndCheckOut(project, checkout, vcs, branch, message)
+ }
+
+ override fun onThrowable(error: Throwable) {
+ AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.contribution.failed"), error)
+ }
+ }.queue()
+ }
+
+ private fun commitAndCheckOut(
+ project: Project,
+ checkout: AnnotaterCheckout,
+ vcs: AnnotaterVcs,
+ branch: String?,
+ commitMessage: String?,
+ ) {
+ // The Javadoc being edited is from the old branch, so stop editing it and restart after switching
+ val javadocEditor = javadocEditor(project, checkout)
+ val reopen = javadocEditor?.source
+ javadocEditor?.finish(force = true)
+
+ object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.contribution.switching"), false) {
+ override fun run(indicator: ProgressIndicator) {
+ vcs.switchContribution(project, checkout, branch, commitMessage, indicator)
+ }
+
+ override fun onThrowable(error: Throwable) {
+ AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.contribution.failed"), error)
+ }
+
+ override fun onFinished() {
+ reopen?.element?.let { AnnotaterJavadocEditing.open(project, it) }
+ }
+ }.queue()
+ }
+
+ private fun deleteContribution(
+ project: Project,
+ checkout: AnnotaterCheckout,
+ vcs: AnnotaterVcs,
+ contribution: AnnotaterContribution,
+ current: Boolean,
+ ) {
+ val message = MCDevBundle("fabric.annotater.contribution.delete.confirm", contribution.displayName) +
+ if (current) "\n\n" + MCDevBundle("fabric.annotater.contribution.delete.confirm.current") else ""
+ val confirmed = Messages.showOkCancelDialog(
+ project,
+ message,
+ MCDevBundle("fabric.annotater.contribution.delete.title"),
+ MCDevBundle("fabric.annotater.contribution.delete.ok"),
+ Messages.getCancelButton(),
+ Messages.getWarningIcon(),
+ ) == Messages.OK
+ if (!confirmed) {
+ return
+ }
+ if (current) {
+ // The Javadoc being edited is from the deleted branch, and its changes are discarded
+ javadocEditor(project, checkout)?.finish(force = true)
+ }
+ object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.contribution.deleting"), false) {
+ override fun run(indicator: ProgressIndicator) {
+ vcs.deleteContribution(project, checkout, contribution.branch, indicator)
+ }
+
+ override fun onSuccess() {
+ AnnotaterNotifications.notifyInfo(
+ project,
+ MCDevBundle("fabric.annotater.contribution.deleted"),
+ MCDevBundle("fabric.annotater.contribution.deleted.content", contribution.displayName),
+ )
+ }
+
+ override fun onThrowable(error: Throwable) {
+ AnnotaterNotifications.notifyError(
+ project,
+ MCDevBundle("fabric.annotater.contribution.delete.failed"),
+ error,
+ )
+ }
+ }.queue()
+ }
+
+ private fun javadocEditor(project: Project, checkout: AnnotaterCheckout): AnnotaterJavadocInlineEditor? =
+ AnnotaterJavadocInlineEditor.current(project)?.takeIf { it.checkout.root == checkout.root }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt
new file mode 100644
index 000000000..1a3c5261c
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt
@@ -0,0 +1,40 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.intellij.lang.documentation.AbstractDocumentationProvider
+import com.intellij.openapi.project.DumbService
+import com.intellij.psi.PsiElement
+
+/**
+ * Shows annotater Javadocs instead of the existing Javadoc of Minecraft classes in Quick Documentation.
+ */
+class AnnotaterJavadocDocumentationProvider : AbstractDocumentationProvider() {
+ override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
+ if (element == null || DumbService.isDumb(element.project)) {
+ return null
+ }
+ val member = AnnotaterJavadocEditing.normalize(element) ?: return null
+ val checkout = AnnotaterJavadocRendering.checkoutForDisplay(member) ?: return null
+ val target = AnnotaterJavadocTarget.fromPsi(member) ?: return null
+ return AnnotaterJavadocRendering.generateDoc(member, checkout.javadocStore.getOrNull(target))
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt
new file mode 100644
index 000000000..77ffebf6d
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt
@@ -0,0 +1,177 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterConfigurable
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs
+import com.intellij.codeInsight.TargetElementUtil
+import com.intellij.openapi.application.ReadAction
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.editor.ex.EditorEx
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.Task
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.wm.IdeFocusManager
+import com.intellij.psi.PsiDocumentManager
+import com.intellij.psi.PsiElement
+import com.intellij.psi.PsiFile
+import com.intellij.psi.PsiJavaFile
+import com.intellij.psi.PsiMember
+import com.intellij.psi.SmartPointerManager
+import com.intellij.psi.impl.compiled.ClsElementImpl
+import com.intellij.psi.util.PsiTreeUtil
+import java.io.IOException
+
+object AnnotaterJavadocEditing {
+ /**
+ * In Fabric projects it's offered even before annotater is set up, to make it discoverable.
+ */
+ fun isOffered(project: Project): Boolean {
+ return AnnotaterSettings.instance.enabled || AnnotaterCheckoutService.isFabricProject(project)
+ }
+
+ /**
+ * Finds the Minecraft class, field or method that the caret is on or in, which may be a reference to it.
+ */
+ fun findTarget(editor: Editor, file: PsiFile): PsiMember? {
+ val referenced = TargetElementUtil.findTargetElement(
+ editor,
+ TargetElementUtil.ELEMENT_NAME_ACCEPTED or TargetElementUtil.REFERENCED_ELEMENT_ACCEPTED,
+ )
+ val candidate = referenced?.let(::normalize)
+ ?: file.findElementAt(editor.caretModel.offset)?.let { element ->
+ PsiTreeUtil.getParentOfType(element, PsiMember::class.java, false)?.let(::normalize)
+ }
+ ?: return null
+ return candidate.takeIf { isDocumentable(it) }
+ }
+
+ fun normalize(element: PsiElement): PsiMember? {
+ val member = (element.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: element) as? PsiMember ?: return null
+ return member.takeIf { AnnotaterJavadocTarget.fromPsi(it) != null }
+ }
+
+ private fun isDocumentable(member: PsiMember): Boolean {
+ val file = AnnotaterCheckoutService.virtualFileOf(member) ?: return false
+ return AnnotaterCheckoutService.isMinecraftFile(member.project, file)
+ }
+
+ fun open(project: Project, editor: Editor, file: PsiFile) {
+ open(project, findTarget(editor, file) ?: return)
+ }
+
+ /**
+ * Edits in an editor embedded in the Minecraft class [member] is displayed in.
+ */
+ fun open(project: Project, member: PsiMember) {
+ val checkout = when (val status = AnnotaterCheckoutService.instance.status(member)) {
+ is AnnotaterCheckoutService.Status.Available -> status.checkout
+ AnnotaterCheckoutService.Status.Disabled -> {
+ AnnotaterConfigurable.Util.show(project)
+ return
+ }
+
+ else -> {
+ AnnotaterNotifications.notifyUnavailable(project, status)
+ return
+ }
+ }
+
+ val pointer = SmartPointerManager.createPointer(member)
+ val vcs = AnnotaterVcs.instance
+ if (!checkout.managed || vcs == null) {
+ openEditor(project, checkout, pointer.element ?: return)
+ return
+ }
+
+ object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.preparing"), true) {
+ override fun run(indicator: ProgressIndicator) {
+ vcs.prepareForEditing(project, checkout)
+ }
+
+ override fun onSuccess() {
+ pointer.element?.let { openEditor(project, checkout, it) }
+ }
+
+ override fun onThrowable(error: Throwable) {
+ AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.preparing.failed"), error)
+ }
+ }.queue()
+ }
+
+ private fun openEditor(project: Project, checkout: AnnotaterCheckout, member: PsiMember) {
+ val target = AnnotaterJavadocTarget.fromPsi(member) ?: return
+ AnnotaterJavadocInlineEditor.current(project)?.let { current ->
+ if (current.target == target && current.checkout.root == checkout.root && current.editor != null) {
+ current.editor?.let { IdeFocusManager.getInstance(project).requestFocus(it.contentComponent, true) }
+ return
+ }
+ }
+
+ val (host, hostMember) = findHost(project, member, target) ?: return
+ val copy = try {
+ ReadAction.computeBlocking {
+ AnnotaterJavadocSourceCopy.create(member, placeholderFor = target) { checkout.javadocStore.get(it) }
+ }
+ } catch (e: IOException) {
+ AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.javadoc.open_failed"), e)
+ return
+ } ?: return
+
+ val file = AnnotaterJavadocVirtualFile(checkout, copy, target)
+ val inlineEditor =
+ AnnotaterJavadocInlineEditor.start(project, file, SmartPointerManager.createPointer(hostMember)) ?: return
+ inlineEditor.show(host, AnnotaterJavadocRendering.displayOffset(hostMember))
+ }
+
+ /**
+ * Finds the editor that displays the top level class of [member], opening it if necessary, and the member that
+ * documents [target] in it.
+ */
+ private fun findHost(
+ project: Project,
+ member: PsiMember,
+ target: AnnotaterJavadocTarget,
+ ): Pair? {
+ fun findIn(editor: Editor?): Pair? {
+ if (editor !is EditorEx) {
+ return null
+ }
+ val file = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) as? PsiJavaFile
+ ?: return null
+ val hostMember = AnnotaterJavadocTarget.declarations(file)
+ .firstOrNull { AnnotaterJavadocTarget.fromPsi(it) == target }
+ ?: return null
+ return editor to hostMember
+ }
+
+ val editorManager = FileEditorManager.getInstance(project)
+ findIn(editorManager.selectedTextEditor)?.let { return it }
+ member.navigate(true)
+ return findIn(editorManager.selectedTextEditor)
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt
new file mode 100644
index 000000000..54585f2e2
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt
@@ -0,0 +1,95 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterPullRequests
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.fileEditor.FileEditor
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.ui.EditorNotificationPanel
+import com.intellij.ui.EditorNotificationProvider
+import com.intellij.ui.HyperlinkLabel
+import java.util.concurrent.CompletableFuture
+import java.util.function.Function
+import javax.swing.JComponent
+
+class AnnotaterJavadocEditorNotificationProvider : EditorNotificationProvider {
+ override fun collectNotificationData(
+ project: Project,
+ file: VirtualFile,
+ ): Function? {
+ val checkout = AnnotaterCheckoutService.instance.checkoutFor(project, file) ?: return null
+ val javadocEditor = AnnotaterJavadocInlineEditor.current(project)?.takeIf { it.checkout.root == checkout.root }
+ val error = javadocEditor?.file?.error
+ val pullRequests = AnnotaterPullRequests.instance
+
+ return Function { fileEditor ->
+ val status =
+ if (error != null) EditorNotificationPanel.Status.Error else EditorNotificationPanel.Status.Info
+ val panel = EditorNotificationPanel(fileEditor, status)
+ panel.text = error ?: MCDevBundle("fabric.annotater.banner", checkout.version, checkout.root)
+ if (javadocEditor != null) {
+ panel.createActionLabel(MCDevBundle("fabric.annotater.javadoc.finish")) {
+ javadocEditor.finish()
+ }
+ }
+
+ val pullRequestAction = pullRequests?.availableAction(checkout)
+ if (pullRequests != null && pullRequestAction != null) {
+ val text = when (pullRequestAction) {
+ AnnotaterPullRequests.Action.CREATE -> MCDevBundle("fabric.annotater.create_pull_request")
+ AnnotaterPullRequests.Action.UPDATE -> MCDevBundle("fabric.annotater.update_pull_request")
+ }
+ panel.createActionLabel(text) {
+ val saved = javadocEditor?.save() ?: CompletableFuture.completedFuture(true)
+ saved.thenAccept { success ->
+ if (success) {
+ ApplicationManager.getApplication().invokeLater {
+ pullRequests.submit(project, checkout)
+ }
+ }
+ }
+ }
+ }
+ // Switching contributions and configuring annotater are in one dropdown, to keep the banner short
+ val vcs = AnnotaterVcs.instance
+ val labelText = if (checkout.managed && vcs != null) {
+ AnnotaterJavadocContributions.currentLabel(vcs, checkout)
+ } else {
+ MCDevBundle("fabric.annotater.menu")
+ }
+ lateinit var label: HyperlinkLabel
+ label = panel.createActionLabel(MCDevBundle("fabric.annotater.dropdown", labelText)) {
+ AnnotaterJavadocContributions.showPopup(project, checkout, label)
+ }
+ if (checkout.managed && vcs != null) {
+ label.setIcon(AllIcons.Vcs.Branch)
+ label.toolTipText = MCDevBundle("fabric.annotater.contribution.tooltip")
+ }
+ panel
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt
new file mode 100644
index 000000000..40fc82e7f
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt
@@ -0,0 +1,32 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.intellij.lang.LanguageFormattingRestriction
+import com.intellij.psi.PsiElement
+
+/**
+ * Prevents reformatting annotater Javadoc editors, which would change the read-only copied source code.
+ */
+class AnnotaterJavadocFormattingRestriction : LanguageFormattingRestriction {
+ override fun isFormatterAllowed(context: PsiElement) =
+ AnnotaterJavadocVirtualFile.of(context.containingFile) == null
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt
new file mode 100644
index 000000000..8cc2d5e24
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt
@@ -0,0 +1,205 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.GeneralAssets
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings
+import com.intellij.codeHighlighting.TextEditorHighlightingPass
+import com.intellij.codeHighlighting.TextEditorHighlightingPassFactory
+import com.intellij.codeHighlighting.TextEditorHighlightingPassFactoryRegistrar
+import com.intellij.codeHighlighting.TextEditorHighlightingPassRegistrar
+import com.intellij.codeInsight.documentation.render.DocRenderManager
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.editor.FoldRegion
+import com.intellij.openapi.editor.Inlay
+import com.intellij.openapi.editor.markup.GutterIconRenderer
+import com.intellij.openapi.editor.markup.HighlighterLayer
+import com.intellij.openapi.editor.markup.HighlighterTargetArea
+import com.intellij.openapi.editor.markup.RangeHighlighter
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.project.DumbAwareAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Key
+import com.intellij.openapi.util.TextRange
+import com.intellij.psi.PsiCompiledElement
+import com.intellij.psi.PsiDocumentManager
+import com.intellij.psi.PsiFile
+import com.intellij.psi.PsiJavaDocumentedElement
+import com.intellij.psi.PsiJavaFile
+import com.intellij.psi.PsiMember
+import com.intellij.psi.SmartPointerManager
+import com.intellij.psi.SmartPsiElementPointer
+import javax.swing.Icon
+
+/**
+ * The IDE's rendered doc comments (reader mode) would show the existing Javadocs again, so they're turned off in these
+ * editors. Javadocs can't be added to the decompiled text itself, as the IDE requires decompiled classes' doc comments
+ * to match their compiled stubs.
+ */
+class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegistrar, TextEditorHighlightingPassFactory {
+ override fun registerHighlightingPassFactory(registrar: TextEditorHighlightingPassRegistrar, project: Project) {
+ registrar.registerTextEditorHighlightingPass(this, null, null, false, -1)
+ }
+
+ override fun createHighlightingPass(file: PsiFile, editor: Editor): TextEditorHighlightingPass? {
+ if (file !is PsiJavaFile || AnnotaterJavadocVirtualFile.of(file) != null) {
+ return null
+ }
+ if (PsiDocumentManager.getInstance(file.project).getDocument(file) !== editor.document) {
+ return null
+ }
+ // Passes are still created when nothing is displayed, so that previous inlays are removed
+ return Pass(file, editor)
+ }
+
+ private class Item(val offset: Int, val javadoc: String, val pointer: SmartPsiElementPointer)
+
+ private class GutterIcon(val offset: Int, val pointer: SmartPsiElementPointer)
+
+ private class Pass(private val file: PsiJavaFile, private val editor: Editor) :
+ TextEditorHighlightingPass(file.project, editor.document, false) {
+ private val documentStamp = editor.document.modificationStamp
+
+ private var showJavadocs = false
+ private var items = emptyList- ()
+ private var gutterIcons = emptyList()
+ private var folds = emptyList()
+
+ override fun doCollectInformation(progress: ProgressIndicator) {
+ val first = file.classes.firstOrNull() ?: return
+ val checkout = AnnotaterCheckoutService.instance.checkoutFor(first) ?: return
+ showJavadocs = AnnotaterSettings.instance.showAnnotaterJavadocs
+ val compiled = file is PsiCompiledElement
+ // The Javadoc being edited is displayed by its editor instead
+ val edited = AnnotaterJavadocInlineEditor.current(myProject)?.takeIf { it.hostEditor === editor }?.target
+
+ val newItems = mutableListOf
- ()
+ val newGutterIcons = mutableListOf()
+ val newFolds = mutableListOf()
+ for (member in AnnotaterJavadocTarget.declarations(file)) {
+ progress.checkCanceled()
+ val target = AnnotaterJavadocTarget.fromPsi(member) ?: continue
+ val existing = if (compiled) null else (member as PsiJavaDocumentedElement).docComment
+ if (existing != null && showJavadocs) {
+ newFolds += existing.textRange
+ }
+ val javadoc = checkout.javadocStore.getOrNull(target) ?: continue
+ val pointer = SmartPointerManager.createPointer(member)
+ newGutterIcons += GutterIcon(member.textOffset, pointer)
+ if (!showJavadocs || target == edited) {
+ continue
+ }
+ newItems += Item(AnnotaterJavadocRendering.displayOffset(member), javadoc, pointer)
+ }
+ items = newItems
+ gutterIcons = newGutterIcons
+ folds = newFolds
+ }
+
+ override fun doApplyInformationToEditor() {
+ val document = editor.document
+ if (document.modificationStamp != documentStamp) {
+ return
+ }
+ val length = document.textLength
+
+ editor.getUserData(Const.INLAYS_KEY)?.forEach { it.dispose() }
+ val inlays = items.filter { it.offset in 0..length }.mapNotNull { item ->
+ val renderer = AnnotaterJavadocInlayRenderer(editor, item.offset, item.javadoc, item.pointer)
+ editor.inlayModel.addBlockElement(item.offset, false, true, 0, renderer)
+ }
+ editor.putUserData(Const.INLAYS_KEY, inlays.takeIf { it.isNotEmpty() })
+
+ editor.getUserData(Const.GUTTER_ICONS_KEY)?.forEach { it.dispose() }
+ val highlighters = gutterIcons.filter { it.offset in 0..length }.map { icon ->
+ editor.markupModel.addRangeHighlighter(
+ icon.offset,
+ icon.offset,
+ HighlighterLayer.ADDITIONAL_SYNTAX,
+ null,
+ HighlighterTargetArea.LINES_IN_RANGE,
+ ).also { it.gutterIconRenderer = EditGutterIconRenderer(icon.pointer) }
+ }
+ editor.putUserData(Const.GUTTER_ICONS_KEY, highlighters.takeIf { it.isNotEmpty() })
+
+ val oldFolds = editor.getUserData(Const.FOLDS_KEY).orEmpty()
+ if (oldFolds.isNotEmpty() || folds.isNotEmpty()) {
+ val foldingModel = editor.foldingModel
+ val newFolds = mutableListOf()
+ foldingModel.runBatchFoldingOperation {
+ oldFolds.filter { it.isValid }.forEach(foldingModel::removeFoldRegion)
+ for (range in folds) {
+ if (range.endOffset > length) {
+ continue
+ }
+ val region = foldingModel.getFoldRegion(range.startOffset, range.endOffset)
+ ?: foldingModel.addFoldRegion(range.startOffset, range.endOffset, "…")
+ ?.also(newFolds::add)
+ region?.isExpanded = false
+ }
+ }
+ editor.putUserData(Const.FOLDS_KEY, newFolds.takeIf { it.isNotEmpty() })
+ }
+
+ if (showJavadocs && DocRenderManager.isDocRenderingEnabled(editor)) {
+ DocRenderManager.setDocRenderingEnabled(editor, false)
+ editor.putUserData(Const.DOC_RENDERING_DISABLED_KEY, true)
+ } else if (!showJavadocs && editor.getUserData(Const.DOC_RENDERING_DISABLED_KEY) == true) {
+ DocRenderManager.resetEditorToDefaultState(editor)
+ editor.putUserData(Const.DOC_RENDERING_DISABLED_KEY, null)
+ }
+ }
+ }
+
+ private class EditGutterIconRenderer(private val pointer: SmartPsiElementPointer) :
+ GutterIconRenderer() {
+ override fun getIcon(): Icon = GeneralAssets.TINY_POTATO
+
+ override fun getTooltipText() = MCDevBundle("fabric.annotater.javadoc.edit")
+
+ override fun isNavigateAction() = true
+
+ override fun getClickAction(): AnAction = EditAction(pointer)
+
+ override fun equals(other: Any?) = other is EditGutterIconRenderer && other.pointer == pointer
+
+ override fun hashCode() = pointer.hashCode()
+ }
+
+ class EditAction(private val pointer: SmartPsiElementPointer) :
+ DumbAwareAction(MCDevBundle("fabric.annotater.javadoc.edit")) {
+ override fun actionPerformed(e: AnActionEvent) {
+ val member = pointer.element ?: return
+ AnnotaterJavadocEditing.open(member.project, member)
+ }
+ }
+
+ private object Const {
+ val INLAYS_KEY = Key.create
>>("mcdev.annotater.javadocInlays")
+ val FOLDS_KEY = Key.create>("mcdev.annotater.javadocFolds")
+ val GUTTER_ICONS_KEY = Key.create>("mcdev.annotater.javadocGutterIcons")
+ val DOC_RENDERING_DISABLED_KEY = Key.create("mcdev.annotater.docRenderingDisabled")
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt
new file mode 100644
index 000000000..7162f5f5a
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt
@@ -0,0 +1,123 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.intellij.ide.highlighter.JavaFileHighlighter
+import com.intellij.openapi.actionSystem.ActionGroup
+import com.intellij.openapi.actionSystem.DefaultActionGroup
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.editor.EditorCustomElementRenderer
+import com.intellij.openapi.editor.Inlay
+import com.intellij.openapi.editor.colors.EditorColorsScheme
+import com.intellij.openapi.editor.colors.EditorFontType
+import com.intellij.openapi.editor.ex.util.LexerEditorHighlighter
+import com.intellij.openapi.editor.markup.TextAttributes
+import com.intellij.pom.java.LanguageLevel
+import com.intellij.psi.PsiMember
+import com.intellij.psi.SmartPsiElementPointer
+import com.intellij.util.text.CharArrayUtil
+import java.awt.Graphics
+import java.awt.Rectangle
+
+/**
+ * Displays an annotater Javadoc above its declaration as the `///` comment it would be in the source, highlighted in
+ * the same way as a doc comment in a Java file.
+ */
+class AnnotaterJavadocInlayRenderer(
+ private val editor: Editor,
+ private val initialOffset: Int,
+ private val javadoc: String,
+ private val pointer: SmartPsiElementPointer,
+) : EditorCustomElementRenderer {
+ override fun calcWidthInPixels(inlay: Inlay<*>): Int {
+ val indent = maxOf(0, startX(inlay) - editor.insets.left)
+ return indent + lines().maxOf { line -> line.sumOf { it.width() } }
+ }
+
+ override fun calcHeightInPixels(inlay: Inlay<*>): Int {
+ return lines().size * editor.lineHeight
+ }
+
+ override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) {
+ val startX = startX(inlay)
+ var y = targetRegion.y + editor.ascent
+ for (line in lines()) {
+ var x = startX
+ for (token in line) {
+ g.font = token.font()
+ g.color = token.attributes?.foregroundColor ?: editor.colorsScheme.defaultForeground
+ g.drawString(token.text, x, y)
+ x += token.width()
+ }
+ y += editor.lineHeight
+ }
+ }
+
+ override fun getContextMenuGroup(inlay: Inlay<*>): ActionGroup =
+ DefaultActionGroup(AnnotaterJavadocInlayPassFactory.EditAction(pointer))
+
+ private fun startX(inlay: Inlay<*>): Int {
+ // The size is first calculated while the inlay is being created, before it has a valid offset. The offset
+ // differs from initialOffset after the document changes, until the highlighting pass replaces the inlay
+ val offset = (if (inlay.isValid) inlay.offset else initialOffset).coerceIn(0, editor.document.textLength)
+ val document = editor.document
+ val lineStart = document.getLineStartOffset(document.getLineNumber(offset))
+ val contentStart = CharArrayUtil.shiftForward(document.immutableCharSequence, lineStart, " \t")
+ return editor.offsetToXY(contentStart, false, true).x
+ }
+
+ private inner class Token(val text: String, val attributes: TextAttributes?) {
+ fun font() = editor.colorsScheme.getFont(EditorFontType.forJavaStyle(attributes?.fontType ?: 0))
+
+ fun width() = editor.contentComponent.getFontMetrics(font()).stringWidth(text)
+ }
+
+ private var lines: List>? = null
+ private var linesScheme: EditorColorsScheme? = null
+
+ private fun lines(): List> {
+ val scheme = editor.colorsScheme
+ lines?.takeIf { linesScheme === scheme }?.let { return it }
+
+ val text = javadoc.lines().joinToString("\n") { if (it.isEmpty()) "///" else "/// $it" }
+ val highlighter = LexerEditorHighlighter(JavaFileHighlighter(LanguageLevel.HIGHEST), scheme)
+ highlighter.setText(text)
+ val lines = mutableListOf>()
+ var tokens = mutableListOf()
+ val iterator = highlighter.createIterator(0)
+ while (!iterator.atEnd()) {
+ for ((index, part) in text.substring(iterator.start, iterator.end).split('\n').withIndex()) {
+ if (index > 0) {
+ lines += tokens
+ tokens = mutableListOf()
+ }
+ if (part.isNotEmpty()) {
+ tokens += Token(part, iterator.textAttributes)
+ }
+ }
+ iterator.advance()
+ }
+ lines += tokens
+ this.lines = lines
+ linesScheme = scheme
+ return lines
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt
new file mode 100644
index 000000000..34d6de60c
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt
@@ -0,0 +1,526 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterListener
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications
+import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
+import com.intellij.codeInsight.documentation.render.DocRenderManager
+import com.intellij.codeInsight.hint.HintManager
+import com.intellij.codeInsight.lookup.LookupManager
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.actionSystem.CommonDataKeys
+import com.intellij.openapi.actionSystem.CommonShortcuts
+import com.intellij.openapi.actionSystem.DataSink
+import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
+import com.intellij.openapi.actionSystem.UiDataProvider
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.service
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.editor.Document
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.editor.EditorFactory
+import com.intellij.openapi.editor.EditorKind
+import com.intellij.openapi.editor.RangeMarker
+import com.intellij.openapi.editor.ScrollType
+import com.intellij.openapi.editor.event.CaretEvent
+import com.intellij.openapi.editor.event.CaretListener
+import com.intellij.openapi.editor.event.DocumentEvent
+import com.intellij.openapi.editor.event.DocumentListener
+import com.intellij.openapi.editor.event.EditorFactoryEvent
+import com.intellij.openapi.editor.event.EditorFactoryListener
+import com.intellij.openapi.editor.ex.EditorEx
+import com.intellij.openapi.editor.impl.EditorEmbeddedComponentManager
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider
+import com.intellij.openapi.project.DumbAwareAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Disposer
+import com.intellij.openapi.util.Key
+import com.intellij.openapi.util.TextRange
+import com.intellij.openapi.vfs.VfsUtil
+import com.intellij.openapi.wm.IdeFocusManager
+import com.intellij.psi.PsiDocumentManager
+import com.intellij.psi.PsiJavaDocumentedElement
+import com.intellij.psi.PsiJavaFile
+import com.intellij.psi.PsiMember
+import com.intellij.psi.SmartPsiElementPointer
+import com.intellij.ui.ColorUtil
+import com.intellij.ui.EditorNotifications
+import com.intellij.util.concurrency.AppExecutorUtil
+import com.intellij.util.ui.JBUI
+import java.awt.BorderLayout
+import java.awt.Dimension
+import java.awt.event.ComponentAdapter
+import java.awt.event.ComponentEvent
+import java.io.IOException
+import java.util.concurrent.CompletableFuture
+import javax.swing.JPanel
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * The editing of one annotater Javadoc, in an editor embedded above its declaration in the Minecraft class it's
+ * displayed in. Only one Javadoc is edited at a time in each project; see [current].
+ *
+ * The embedded editor edits [file], a copy of the class's source file, so that the IDE's Java support works as it
+ * would in a real source file. Everything except the edited doc comment is folded away and guarded against edits.
+ * The copy isn't updated if the annotater Javadocs change on disk while it's being edited, and saving only writes the
+ * edited Javadoc.
+ */
+class AnnotaterJavadocInlineEditor private constructor(
+ val project: Project,
+ val file: AnnotaterJavadocVirtualFile,
+ private val document: Document,
+ /**
+ * Kept so that editing can be restarted when the checkout switches branch.
+ */
+ val source: SmartPsiElementPointer?,
+) : Disposable {
+ /**
+ * Delayed save until the user stops typing.
+ */
+ private var saveJob: Job? = null
+
+ private val guards = mutableListOf()
+
+ @Volatile
+ private var dirty = false
+
+ @Volatile
+ var hostEditor: Editor? = null
+ private set
+
+ var editor: EditorEx? = null
+ private set
+
+ val checkout: AnnotaterCheckout
+ get() = file.checkout
+
+ val target: AnnotaterJavadocTarget
+ get() = file.target
+
+ init {
+ installGuardedBlocks()
+ document.addDocumentListener(
+ object : DocumentListener {
+ override fun documentChanged(event: DocumentEvent) {
+ dirty = true
+ saveJob?.cancel()
+ saveJob = project.service().coroutineScope.launch(Dispatchers.EDT) {
+ delay(SAVE_DELAY_MS.milliseconds)
+ // Otherwise save would cancel this job, which cancels the save partway through
+ saveJob = null
+ save()
+ }
+ }
+ },
+ this,
+ )
+ }
+
+ /**
+ * The whole lines of the edited doc comment, excluding the line break at the end.
+ */
+ private fun commentRange(): TextRange? {
+ val documentManager = PsiDocumentManager.getInstance(project)
+ documentManager.commitDocument(document)
+ val psiFile = documentManager.getPsiFile(document) as? PsiJavaFile ?: return null
+ val member = file.copy.memberOf(psiFile, target) ?: return null
+ val comment = (member as PsiJavaDocumentedElement).docComment ?: return null
+ val start = document.getLineStartOffset(document.getLineNumber(comment.textRange.startOffset))
+ val end = document.getLineEndOffset(document.getLineNumber(comment.textRange.endOffset))
+ return TextRange(start, end)
+ }
+
+ /**
+ * Makes everything outside of the edited doc comment read-only. The line break after the comment is read-only too,
+ * so that the declaration can't be joined onto the comment. The guards are greedy at the ends of the document, so
+ * nothing can be inserted there, but not next to the comment, so that text can be inserted at its start and end.
+ */
+ private fun installGuardedBlocks() {
+ val range = commentRange() ?: return
+ addGuard(0, range.startOffset, greedyToLeft = true, greedyToRight = false)
+ addGuard(range.endOffset, document.textLength, greedyToLeft = false, greedyToRight = true)
+ }
+
+ private fun addGuard(start: Int, end: Int, greedyToLeft: Boolean, greedyToRight: Boolean) {
+ if (end <= start) {
+ return
+ }
+ val guard = document.createGuardedBlock(start, end)
+ guard.isGreedyToLeft = greedyToLeft
+ guard.isGreedyToRight = greedyToRight
+ guards += guard
+ }
+
+ fun show(host: EditorEx, offset: Int) {
+ val range = commentRange() ?: return
+ val editor = EditorFactory.getInstance().createEditor(document, project, file, false, EditorKind.MAIN_EDITOR)
+ as EditorEx
+ this.editor = editor
+ hostEditor = host
+ configure(editor, host)
+
+ // Hide everything except the doc comment. Each fold includes a line break, so it doesn't leave an empty line
+ editor.foldingModel.runBatchFoldingOperation {
+ editor.foldingModel.createFoldRegion(0, range.startOffset, "", null, true)
+ editor.foldingModel.createFoldRegion(range.endOffset, document.textLength, "", null, true)
+ }
+ // The end of the last line is displayed at the end of the hidden text after it, and the start of the first line
+ // at the start of the hidden text before it, so keep the caret within the comment
+ editor.caretModel.addCaretListener(
+ object : CaretListener {
+ override fun caretPositionChanged(event: CaretEvent) {
+ val caret = event.caret
+ val start = guards.firstOrNull()?.endOffset ?: return
+ val end = guards.lastOrNull()?.startOffset ?: return
+ if (caret.offset < start) {
+ caret.moveToOffset(start)
+ } else if (caret.offset > end) {
+ caret.moveToOffset(end)
+ }
+ }
+ },
+ this,
+ )
+ FinishAction().registerCustomShortcutSet(CommonShortcuts.ESCAPE, editor.contentComponent, this)
+
+ val component = object : JPanel(BorderLayout()), UiDataProvider {
+ override fun uiDataSnapshot(sink: DataSink) {
+ // Otherwise actions such as undo use the host editor, which is the file editor this is embedded in
+ sink[PlatformCoreDataKeys.FILE_EDITOR] = TextEditorProvider.getInstance().getTextEditor(editor)
+ sink[CommonDataKeys.VIRTUAL_FILE] = file
+ }
+
+ override fun getPreferredSize(): Dimension {
+ val insets = insets
+ return Dimension(
+ super.getPreferredSize().width,
+ editor.contentComponent.preferredSize.height + insets.top + insets.bottom,
+ )
+ }
+ }
+ component.border = JBUI.Borders.customLine(
+ ColorUtil.mix(host.colorsScheme.defaultBackground, host.colorsScheme.defaultForeground, 0.25),
+ 1,
+ )
+ component.add(editor.component, BorderLayout.CENTER)
+ // The embedded editor's height depends on its text and its width, which changes with the host editor's width
+ document.addDocumentListener(
+ object : DocumentListener {
+ override fun documentChanged(event: DocumentEvent) {
+ ApplicationManager.getApplication().invokeLater({ component.revalidate() }) { isDisposed }
+ }
+ },
+ this,
+ )
+ component.addComponentListener(
+ object : ComponentAdapter() {
+ override fun componentResized(e: ComponentEvent) {
+ ApplicationManager.getApplication().invokeLater({ component.revalidate() }) { isDisposed }
+ }
+ },
+ )
+
+ val inlay = EditorEmbeddedComponentManager.getInstance().addComponent(
+ host,
+ component,
+ EditorEmbeddedComponentManager.Properties(
+ EditorEmbeddedComponentManager.ResizePolicy.none(),
+ null,
+ false,
+ true,
+ false,
+ true,
+ 0,
+ offset,
+ ),
+ )
+ inlay?.let { Disposer.register(this, it) }
+
+ // Finish editing when the Minecraft class is closed
+ EditorFactory.getInstance().addEditorFactoryListener(
+ object : EditorFactoryListener {
+ override fun editorReleased(event: EditorFactoryEvent) {
+ if (event.editor === host) {
+ finish(force = true)
+ }
+ }
+ },
+ this,
+ )
+
+ host.scrollingModel.scrollTo(host.offsetToLogicalPosition(offset), ScrollType.MAKE_VISIBLE)
+ editor.caretModel.moveToOffset(document.getLineEndOffset(document.getLineNumber(range.startOffset)))
+ IdeFocusManager.getInstance(project).requestFocus(editor.contentComponent, true)
+ restartHostHighlighting()
+ }
+
+ /**
+ * Finishes editing when Escape is pressed, unless there's something else for Escape to close.
+ */
+ private inner class FinishAction : DumbAwareAction() {
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+ override fun update(e: AnActionEvent) {
+ val editor = editor
+ e.presentation.isEnabled = editor != null &&
+ LookupManager.getActiveLookup(editor) == null &&
+ !editor.selectionModel.hasSelection() &&
+ editor.caretModel.caretCount == 1
+ }
+
+ override fun actionPerformed(e: AnActionEvent) {
+ HintManager.getInstance().hideAllHints()
+ finish()
+ }
+ }
+
+ private fun configure(editor: EditorEx, host: EditorEx) {
+ editor.settings.apply {
+ isLineNumbersShown = false
+ isFoldingOutlineShown = false
+ isLineMarkerAreaShown = false
+ setGutterIconsShown(false)
+ isRightMarginShown = false
+ isIndentGuidesShown = false
+ isVirtualSpace = false
+ isAdditionalPageAtBottom = false
+ additionalLinesCount = 0
+ additionalColumnsCount = 0
+ isUseSoftWraps = true
+ // Java folds doc comments and imports by default, which would hide the edited comment
+ isAutoCodeFoldingEnabled = false
+ }
+ editor.setVerticalScrollbarVisible(false)
+ editor.setHorizontalScrollbarVisible(false)
+ // Let the host editor scroll instead
+ editor.scrollPane.isWheelScrollingEnabled = false
+ editor.colorsScheme.setEditorFontSize(host.colorsScheme.editorFontSize2D)
+ editor.setBackgroundColor(
+ ColorUtil.mix(host.colorsScheme.defaultBackground, host.colorsScheme.defaultForeground, 0.04),
+ )
+ DocRenderManager.setDocRenderingEnabled(editor, false)
+ }
+
+ private fun restartHostHighlighting() {
+ val host = hostEditor ?: return
+ val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(host.document) ?: return
+ DaemonCodeAnalyzer.getInstance(project).restart(psiFile, this)
+ }
+
+ private val isDisposed: Boolean
+ get() = project.getUserData(CURRENT_KEY) !== this
+
+ /**
+ * Saves pending changes to the annotater checkout. The returned future completes with false if the Javadoc
+ * couldn't be saved, in which case [AnnotaterJavadocVirtualFile.error] says why.
+ */
+ fun save(): CompletableFuture {
+ ApplicationManager.getApplication().assertIsDispatchThread()
+ saveJob?.cancel()
+ saveJob = null
+ if (!dirty || project.isDisposed) {
+ return CompletableFuture.completedFuture(file.error == null)
+ }
+ dirty = false
+
+ return when (val result = extract()) {
+ is AnnotaterJavadocSourceCopy.ExtractResult.Error -> {
+ saved(result.message)
+ CompletableFuture.completedFuture(false)
+ }
+
+ is AnnotaterJavadocSourceCopy.ExtractResult.Success -> {
+ val javadoc = result.javadocs[target]
+ // Writing the mapping file is slow, so do it in the background, one save at a time
+ CompletableFuture.supplyAsync(
+ {
+ val error = write(javadoc)
+ ApplicationManager.getApplication().invokeLater { saved(error) }
+ error == null
+ },
+ saveExecutor,
+ )
+ }
+ }
+ }
+
+ private fun extract(): AnnotaterJavadocSourceCopy.ExtractResult {
+ val documentManager = PsiDocumentManager.getInstance(project)
+ documentManager.commitDocument(document)
+ val psiFile = documentManager.getPsiFile(document) as? PsiJavaFile
+ ?: return AnnotaterJavadocSourceCopy.ExtractResult.Error(
+ MCDevBundle("fabric.annotater.javadoc.error.structure_changed"),
+ )
+ return file.copy.extractJavadocs(psiFile) { isGuarded(document, it) }
+ }
+
+ private fun saved(error: String?) {
+ if (error != null) {
+ dirty = true
+ }
+ if (error != file.error) {
+ file.error = error
+ EditorNotifications.getInstance(project).updateAllNotifications()
+ }
+ }
+
+ private fun write(javadoc: String?): String? {
+ val store = checkout.javadocStore
+ val outerClass = file.outerClass
+ return try {
+ store.invalidate(outerClass)
+ if (store.get(target) != javadoc) {
+ store.setAll(mapOf(target to javadoc))
+ // Refreshing only the file wouldn't notice it if the VFS hasn't loaded it yet, such as when it's created
+ val mappingFile = store.fileFor(outerClass).toFile()
+ VfsUtil.markDirtyAndRefresh(false, false, true, mappingFile.parentFile, mappingFile)
+ ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC)
+ .javadocsChanged(checkout.root, outerClass)
+ }
+ null
+ } catch (e: IOException) {
+ thisLogger().info("Failed to save annotater Javadoc of $target", e)
+ MCDevBundle("fabric.annotater.javadoc.error.io", e.message ?: e.toString())
+ }
+ }
+
+ /**
+ * Saves the Javadoc and stops editing it. If it can't be saved because the user wrote something annotater doesn't
+ * support, editing continues so that the user can fix it, unless [force] is true.
+ *
+ * @return whether editing stopped
+ */
+ fun finish(force: Boolean = false): Boolean {
+ if (isDisposed) {
+ return true
+ }
+ if (!force && dirty && extract() is AnnotaterJavadocSourceCopy.ExtractResult.Error) {
+ save()
+ editor?.let { editor ->
+ file.error?.let { HintManager.getInstance().showErrorHint(editor, it) }
+ }
+ return false
+ }
+ save().thenAccept { success ->
+ if (!success) {
+ ApplicationManager.getApplication().invokeLater {
+ AnnotaterNotifications.notifyInfo(
+ project,
+ MCDevBundle("fabric.annotater.javadoc.save_failed"),
+ file.error.orEmpty(),
+ )
+ }
+ }
+ }
+ Disposer.dispose(this)
+ return true
+ }
+
+ override fun dispose() {
+ val wasCurrent = project.getUserData(CURRENT_KEY) === this
+ if (wasCurrent) {
+ project.putUserData(CURRENT_KEY, null)
+ }
+ saveJob?.cancel()
+ saveJob = null
+ editor?.let(EditorFactory.getInstance()::releaseEditor)
+ editor = null
+ for (guard in guards) {
+ document.removeGuardedBlock(guard)
+ }
+ guards.clear()
+ if (wasCurrent && !project.isDisposed) {
+ restartHostHighlighting()
+ EditorNotifications.getInstance(project).updateAllNotifications()
+ }
+ hostEditor = null
+ }
+
+ companion object {
+ private const val SAVE_DELAY_MS = 750L
+
+ private val CURRENT_KEY = Key.create("mcdev.annotater.javadocInlineEditor")
+
+ private val saveExecutor =
+ AppExecutorUtil.createBoundedApplicationPoolExecutor("Annotater Javadoc saving", 1)
+
+ fun current(project: Project): AnnotaterJavadocInlineEditor? = project.getUserData(CURRENT_KEY)
+
+ /**
+ * The parent of the inline editors of a project, so they're disposed with it, and the scope of their
+ * coroutines. Plugins shouldn't use the project itself as a parent disposable, as that leaks the plugin's
+ * classes if it's unloaded while the project is open.
+ */
+ @Service(Service.Level.PROJECT)
+ private class ProjectScope(val coroutineScope: CoroutineScope) : Disposable {
+ override fun dispose() {
+ }
+ }
+
+ /**
+ * Whether [range] of [document] is entirely within one guarded block, i.e. the user didn't write it.
+ */
+ fun isGuarded(document: Document, range: TextRange): Boolean {
+ val guard = document.getRangeGuard(range.startOffset, range.endOffset) ?: return false
+ return guard.startOffset <= range.startOffset && range.endOffset <= guard.endOffset
+ }
+
+ /**
+ * Finishes the previous editing in the project first. Returns null if that couldn't be finished, or the
+ * target's doc comment can't be found in [file].
+ */
+ fun start(
+ project: Project,
+ file: AnnotaterJavadocVirtualFile,
+ source: SmartPsiElementPointer? = null,
+ ): AnnotaterJavadocInlineEditor? {
+ current(project)?.let { previous ->
+ if (!previous.finish()) {
+ return null
+ }
+ }
+ val document = FileDocumentManager.getInstance().getDocument(file) ?: return null
+ val inlineEditor = AnnotaterJavadocInlineEditor(project, file, document, source)
+ if (inlineEditor.guards.isEmpty()) {
+ Disposer.dispose(inlineEditor)
+ return null
+ }
+ project.putUserData(CURRENT_KEY, inlineEditor)
+ Disposer.register(project.service(), inlineEditor)
+ EditorNotifications.getInstance(project).updateAllNotifications()
+ return inlineEditor
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt
new file mode 100644
index 000000000..d480e8c64
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt
@@ -0,0 +1,50 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.intellij.codeInsight.daemon.impl.IntentionActionFilter
+import com.intellij.codeInsight.intention.IntentionAction
+import com.intellij.psi.PsiFile
+import com.intellij.psi.javadoc.PsiDocComment
+import com.intellij.psi.util.PsiTreeUtil
+
+/**
+ * Hides intentions and quick fixes in the copies of Minecraft source files that annotater Javadocs are edited in,
+ * except in the edited Javadoc comment. Guarded blocks only stop the user from typing in the hidden source code, not
+ * other changes to the document, and fixes that apply to the whole file (with an offset of -1) could change it.
+ */
+class AnnotaterJavadocIntentionActionFilter : IntentionActionFilter {
+ override fun accept(intentionAction: IntentionAction, psiFile: PsiFile?, offset: Int): Boolean {
+ if (psiFile == null || AnnotaterJavadocVirtualFile.of(psiFile) == null) {
+ return true
+ }
+ if (offset < 0) {
+ return false
+ }
+ val document = psiFile.viewProvider.document
+ // The caret can be just after the end of a comment
+ return listOf(offset, offset - 1).any { candidate ->
+ val element = psiFile.findElementAt(candidate) ?: return@any false
+ val comment = PsiTreeUtil.getParentOfType(element, PsiDocComment::class.java, false) ?: return@any false
+ !AnnotaterJavadocInlineEditor.isGuarded(document, comment.textRange)
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt
new file mode 100644
index 000000000..87b3090d5
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt
@@ -0,0 +1,73 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterListener
+import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.project.ProjectManager
+import com.intellij.openapi.vfs.newvfs.BulkFileListenerBackgroundable
+import com.intellij.openapi.vfs.newvfs.events.VFileEvent
+import com.intellij.ui.EditorNotifications
+import java.nio.file.Path
+
+class AnnotaterJavadocRefresher : AnnotaterListener, BulkFileListenerBackgroundable {
+ override fun checkoutsChanged() {
+ refresh(updateNotifications = true)
+ }
+
+ override fun javadocsChanged(checkoutRoot: Path, outerClass: String) {
+ refresh()
+ }
+
+ override fun after(events: List) {
+ val paths = events.mapNotNull { event ->
+ val path = event.path
+ path.takeIf { it.endsWith(".mapping") || it.endsWith("/config.json") }?.let { Path.of(it) }
+ }
+ if (paths.isEmpty()) {
+ return
+ }
+ val (changedJavadocs, configChanged) = AnnotaterCheckoutService.instance.filesChanged(paths)
+ val publisher = ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC)
+ if (configChanged) {
+ publisher.checkoutsChanged()
+ }
+ for ((root, outerClass) in changedJavadocs) {
+ publisher.javadocsChanged(root, outerClass)
+ }
+ }
+
+ private fun refresh(updateNotifications: Boolean = false) {
+ ApplicationManager.getApplication().invokeLater {
+ for (project in ProjectManager.getInstance().openProjects) {
+ if (!project.isDisposed) {
+ DaemonCodeAnalyzer.getInstance(project).restart(this)
+ if (updateNotifications) {
+ // The banner is shown wherever there's a checkout
+ EditorNotifications.getInstance(project).updateAllNotifications()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt
new file mode 100644
index 000000000..af52e9e32
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt
@@ -0,0 +1,65 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings
+import com.intellij.lang.java.JavaDocumentationProvider
+import com.intellij.psi.PsiCompiledElement
+import com.intellij.psi.PsiJavaDocumentedElement
+import com.intellij.psi.PsiMember
+import com.intellij.psi.util.PsiTreeUtil
+
+/**
+ * Renders annotater Javadocs with the IDE's own Java documentation renderer, by rendering the declaration in a copy of
+ * its source file that has the annotater Javadoc as its doc comment.
+ */
+object AnnotaterJavadocRendering {
+ fun checkoutForDisplay(member: PsiMember): AnnotaterCheckout? {
+ if (!AnnotaterSettings.instance.showAnnotaterJavadocs) {
+ return null
+ }
+ return AnnotaterCheckoutService.instance.checkoutFor(member)
+ }
+
+ /**
+ * The offset that the annotater Javadoc of [member] is displayed above: its declaration, after any existing
+ * doc comment.
+ */
+ fun displayOffset(member: PsiMember): Int {
+ val existing = if (member is PsiCompiledElement) null else (member as? PsiJavaDocumentedElement)?.docComment
+ return existing?.let { PsiTreeUtil.skipWhitespacesAndCommentsForward(it) }?.textRange?.startOffset
+ ?: member.textRange.startOffset
+ }
+
+ private fun copiedDeclaration(member: PsiMember, javadoc: String?): PsiJavaDocumentedElement? {
+ val target = AnnotaterJavadocTarget.fromPsi(member) ?: return null
+ val copy = AnnotaterJavadocSourceCopy.create(member) { if (it == target) javadoc else null } ?: return null
+ val file = copy.createPsiFile(member.project) ?: return null
+ return copy.memberOf(file, target) as? PsiJavaDocumentedElement
+ }
+
+ fun generateDoc(member: PsiMember, javadoc: String?): String? {
+ val declaration = copiedDeclaration(member, javadoc) ?: return null
+ return JavaDocumentationProvider.generateDocStatic(declaration, null)
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt
new file mode 100644
index 000000000..323903fba
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt
@@ -0,0 +1,219 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.intellij.ide.highlighter.JavaFileType
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.TextRange
+import com.intellij.pom.java.LanguageLevel
+import com.intellij.psi.PsiClass
+import com.intellij.psi.PsiComment
+import com.intellij.psi.PsiCompiledElement
+import com.intellij.psi.PsiJavaDocumentedElement
+import com.intellij.psi.PsiJavaFile
+import com.intellij.psi.PsiManager
+import com.intellij.psi.PsiMember
+import com.intellij.psi.PsiWhiteSpace
+import com.intellij.psi.javadoc.PsiDocComment
+import com.intellij.psi.util.PsiTreeUtil
+import com.intellij.psi.util.PsiUtil
+import com.intellij.testFramework.LightVirtualFile
+
+/**
+ * A copy of the source file of a Minecraft class (its attached sources, or otherwise its decompiled text), with the
+ * existing Javadocs replaced by the annotater Javadocs as Markdown doc comments. This lets the IDE's own Java support
+ * be used to edit and render annotater Javadocs in their real context.
+ *
+ * The member at index `i` of [AnnotaterJavadocTarget.declarations] documents `targets[i]`, or nothing if that is null,
+ * e.g. for members of other top level classes in the same file.
+ */
+class AnnotaterJavadocSourceCopy(
+ val fileName: String,
+ val text: String,
+ val targets: List,
+ val languageLevel: LanguageLevel,
+) {
+ /**
+ * The internal name of the top level class whose Javadocs this copy edits.
+ */
+ val outerClass: String
+ get() = targets.firstNotNullOf { it }.outerClass
+
+ fun createPsiFile(project: Project): PsiJavaFile? {
+ return PsiManager.getInstance(project).findFile(createVirtualFile()) as? PsiJavaFile
+ }
+
+ private fun createVirtualFile(): LightVirtualFile = LightVirtualFile(fileName, JavaFileType.INSTANCE, text).also {
+ it.putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, languageLevel)
+ }
+
+ /**
+ * Finds the member of [file], which must have been created from this copy, that documents [target].
+ */
+ fun memberOf(file: PsiJavaFile, target: AnnotaterJavadocTarget): PsiMember? {
+ val index = targets.indexOf(target)
+ return if (index < 0) null else AnnotaterJavadocTarget.declarations(file).getOrNull(index)
+ }
+
+ /**
+ * Finds the target documented by [member] of [file], which must have been created from this copy.
+ */
+ fun targetOf(file: PsiJavaFile, member: PsiMember): AnnotaterJavadocTarget? {
+ val index = AnnotaterJavadocTarget.declarations(file).indexOf(member)
+ return if (index < 0) null else targets.getOrNull(index)
+ }
+
+ sealed class ExtractResult {
+ data class Success(val javadocs: Map) : ExtractResult()
+ data class Error(val message: String) : ExtractResult()
+ }
+
+ /**
+ * Reads the Javadoc of every target back out of [file], which must have been created from this copy.
+ *
+ * @param isOriginal whether a range of [file] is part of the source that the user can't edit, so that comments
+ * there are allowed
+ */
+ fun extractJavadocs(file: PsiJavaFile, isOriginal: (TextRange) -> Boolean = { false }): ExtractResult {
+ val comments = PsiTreeUtil.collectElementsOfType(file, PsiComment::class.java)
+ for (comment in comments) {
+ if (isOriginal(comment.textRange)) {
+ continue
+ }
+ if (comment is PsiDocComment) {
+ if (!comment.isMarkdownComment) {
+ return ExtractResult.Error(MCDevBundle("fabric.annotater.javadoc.error.not_markdown"))
+ }
+ } else if (PsiTreeUtil.getParentOfType(comment, PsiDocComment::class.java) == null) {
+ return ExtractResult.Error(MCDevBundle("fabric.annotater.javadoc.error.plain_comment"))
+ }
+ }
+
+ val members = AnnotaterJavadocTarget.declarations(file)
+ if (members.size != targets.size) {
+ return ExtractResult.Error(MCDevBundle("fabric.annotater.javadoc.error.structure_changed"))
+ }
+ val javadocs = LinkedHashMap()
+ for ((member, target) in members.zip(targets)) {
+ if (target != null) {
+ javadocs[target] = (member as PsiJavaDocumentedElement).docComment?.let(::extractMarkdown)
+ }
+ }
+ return ExtractResult.Success(javadocs)
+ }
+
+ companion object {
+ /**
+ * Removes the `///` prefixes and the common indentation. Returns null when the comment is blank.
+ */
+ private fun extractMarkdown(comment: PsiDocComment): String? {
+ val lines = comment.text.lines().map { line ->
+ val trimmed = line.trimStart()
+ if (trimmed.startsWith("///")) trimmed.substring(3) else trimmed
+ }
+ val indent = lines.filter { it.isNotBlank() }.minOfOrNull { line -> line.takeWhile { it == ' ' }.length }
+ ?: return null
+ val text = lines.joinToString("\n") { if (it.isBlank()) "" else it.substring(indent).trimEnd() }
+ .trim('\n')
+ return text.ifEmpty { null }
+ }
+
+ private fun formatMarkdown(markdown: String, indent: String): String {
+ return markdown.lines().joinToString("") { line ->
+ if (line.isEmpty()) "$indent///\n" else "$indent/// $line\n"
+ }
+ }
+
+ /**
+ * Formats Markdown as a `///` doc comment to insert at a column after [indent], followed by [indent] again for
+ * the declaration after it.
+ */
+ private fun formatInsertedMarkdown(markdown: String, indent: String): String {
+ // Leave a space after an empty first line, so the user can start typing there straight away
+ return "/// " + formatMarkdown(markdown, indent).removePrefix("$indent///").removePrefix(" ") + indent
+ }
+
+ /**
+ * The source file that [clazz] is shown in: its attached sources, or otherwise its decompiled text.
+ */
+ private fun sourceFile(clazz: PsiClass): PsiJavaFile? {
+ val file = clazz.navigationElement.containingFile
+ return (if (file is PsiCompiledElement) file.mirror else file) as? PsiJavaFile
+ }
+
+ /**
+ * Copies the source file of the top level class containing [element], a class, field or method.
+ *
+ * @param placeholderFor a target to add an empty doc comment to if it doesn't have an annotater Javadoc, so
+ * that the user can start typing one straight away
+ * @param javadoc looks up the current annotater Javadoc of a target
+ */
+ fun create(
+ element: PsiMember,
+ placeholderFor: AnnotaterJavadocTarget? = null,
+ javadoc: (AnnotaterJavadocTarget) -> String?,
+ ): AnnotaterJavadocSourceCopy? {
+ val outermost = PsiTreeUtil.getTopmostParentOfType(element, PsiClass::class.java)
+ ?: element as? PsiClass
+ ?: return null
+ val outerClass = AnnotaterJavadocTarget.fromPsi(outermost)?.outerClass ?: return null
+ val source = sourceFile(outermost) ?: return null
+ val text = source.text
+
+ val targets = mutableListOf()
+ // The replacements of the existing doc comments, in text order
+ val replacements = mutableListOf>()
+ for (member in AnnotaterJavadocTarget.declarations(source)) {
+ val target = AnnotaterJavadocTarget.fromPsi(member)?.takeIf { it.outerClass == outerClass }
+ val existing = (member as PsiJavaDocumentedElement).docComment
+ val start = existing?.textRange?.startOffset ?: member.textRange.startOffset
+ val lineStart = text.lastIndexOf('\n', start - 1) + 1
+ val indent = text.substring(lineStart, start)
+ // Doc comments can only be inserted above members that start their own line
+ if (target == null || indent.isNotBlank()) {
+ targets += null
+ continue
+ }
+ targets += target
+
+ val markdown = javadoc(target) ?: "".takeIf { target == placeholderFor }
+ val end = existing?.let { (it.nextSibling as? PsiWhiteSpace ?: it).textRange.endOffset } ?: start
+ if (markdown != null || existing != null) {
+ val replacement = markdown?.let { formatInsertedMarkdown(it, indent) }.orEmpty()
+ replacements += TextRange(start, end) to replacement
+ }
+ }
+ if (targets.all { it == null }) {
+ return null
+ }
+
+ val sb = StringBuilder(text)
+ for ((range, replacement) in replacements.sortedByDescending { it.first.startOffset }) {
+ sb.replace(range.startOffset, range.endOffset, replacement)
+ }
+ // Need at least Java 23 to parse Markdown Javadocs
+ val languageLevel = PsiUtil.getLanguageLevel(source).takeIf { it.isAtLeast(LanguageLevel.JDK_23) }
+ ?: LanguageLevel.JDK_23
+ return AnnotaterJavadocSourceCopy("${outermost.name}.java", sb.toString(), targets, languageLevel)
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt
new file mode 100644
index 000000000..7b9b86df5
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt
@@ -0,0 +1,277 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.intellij.openapi.diagnostic.thisLogger
+import java.io.IOException
+import java.io.StringReader
+import java.io.StringWriter
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.exists
+import kotlin.io.path.readText
+import kotlin.io.path.writeText
+import net.fabricmc.mappingio.MappedElementKind
+import net.fabricmc.mappingio.MappingUtil
+import net.fabricmc.mappingio.MappingVisitor
+import net.fabricmc.mappingio.adapter.EmptyElementFilter
+import net.fabricmc.mappingio.adapter.ForwardingMappingVisitor
+import net.fabricmc.mappingio.format.enigma.EnigmaFileReader
+import net.fabricmc.mappingio.format.enigma.EnigmaFileWriter
+import net.fabricmc.mappingio.tree.MappingTree
+import net.fabricmc.mappingio.tree.MemoryMappingTree
+import net.fabricmc.mappingio.tree.VisitOrder
+import org.jetbrains.annotations.VisibleForTesting
+
+/**
+ * Reads and writes the Javadoc comments stored in an annotater `mappings` directory.
+ *
+ * Use [forDirectory] rather than the constructor outside of tests, so that every checkout of a directory sees the
+ * comments the others write.
+ */
+class AnnotaterJavadocStore @VisibleForTesting constructor(val mappingsDir: Path) {
+ private val cache = ConcurrentHashMap()
+
+ fun fileFor(outerClass: String): Path = mappingsDir.resolve("$outerClass.mapping")
+
+ @Throws(IOException::class)
+ fun get(target: AnnotaterJavadocTarget): String? {
+ val clazz = treeFor(target.outerClass).getClass(target.owner) ?: return null
+ return when (target) {
+ is AnnotaterJavadocTarget.ClassTarget -> clazz.comment
+ is AnnotaterJavadocTarget.FieldTarget -> clazz.getField(target.name, target.descriptor)?.comment
+ is AnnotaterJavadocTarget.MethodTarget -> clazz.getMethod(target.name, target.descriptor)?.comment
+ }
+ }
+
+ /**
+ * Like [get], but returns null if the `.mapping` file can't be read, for displaying Javadocs.
+ */
+ fun getOrNull(target: AnnotaterJavadocTarget): String? {
+ return try {
+ if (fileFor(target.outerClass).exists()) get(target) else null
+ } catch (e: IOException) {
+ thisLogger().info("Failed to read annotater Javadoc of $target", e)
+ null
+ }
+ }
+
+ /**
+ * Sets the comment for [target], or removes it when [comment] is null or empty, and writes the `.mapping` file
+ * of its outer class. The file is deleted when it no longer contains any comments.
+ */
+ @Throws(IOException::class)
+ fun set(target: AnnotaterJavadocTarget, comment: String?) {
+ setAll(mapOf(target to comment))
+ }
+
+ /**
+ * All targets must share the same outer class.
+ */
+ @Throws(IOException::class)
+ @Synchronized
+ fun setAll(comments: Map) {
+ if (comments.isEmpty()) {
+ return
+ }
+ val outerClass = comments.keys.first().outerClass
+ require(comments.keys.all { it.outerClass == outerClass }) { "All targets must share the same outer class" }
+
+ val tree = copyTree(treeFor(outerClass))
+ for ((target, comment) in comments) {
+ setInTree(tree, target, comment?.takeIf { it.isNotEmpty() })
+ }
+
+ val text = writeTree(tree)
+ val file = fileFor(outerClass)
+ if (text.isEmpty()) {
+ file.deleteIfExists()
+ } else {
+ Files.createDirectories(file.parent)
+ file.writeText(text)
+ }
+ cache[outerClass] = tree
+ }
+
+ fun invalidate(outerClass: String) {
+ cache.remove(outerClass)
+ }
+
+ @Throws(IOException::class)
+ private fun treeFor(outerClass: String): MemoryMappingTree {
+ cache[outerClass]?.let { return it }
+ val file = fileFor(outerClass)
+ val tree = if (file.exists()) readFile(file.readText(), file.toString(), outerClass) else newTree()
+ return cache.putIfAbsent(outerClass, tree) ?: tree
+ }
+
+ companion object {
+ private val stores = ConcurrentHashMap()
+
+ /**
+ * The store of [mappingsDir], shared by all of its checkouts. The checkout objects are replaced whenever the
+ * checkouts change, such as when a contribution branch is created on the first edit, but an editor started
+ * before that keeps saving through the old one.
+ */
+ fun forDirectory(mappingsDir: Path): AnnotaterJavadocStore =
+ stores.computeIfAbsent(mappingsDir.normalize(), ::AnnotaterJavadocStore)
+
+ fun invalidateAll() {
+ for (store in stores.values) {
+ store.cache.clear()
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun readFile(text: String, path: String, expectedOuterClass: String? = null): MemoryMappingTree {
+ val tree = MemoryMappingTree()
+ try {
+ EnigmaFileReader.read(StringReader(text), withoutMethodVariables(tree))
+ } catch (e: IOException) {
+ throw IOException("Invalid Enigma mapping file $path: ${e.message}", e)
+ } catch (e: RuntimeException) {
+ throw IOException("Invalid Enigma mapping file $path: ${e.message}", e)
+ }
+
+ val owners = tree.classes.mapTo(mutableSetOf()) { AnnotaterJavadocTarget.outerClassName(it.srcName) }
+ if (owners.size > 1) {
+ throw IOException("Enigma mapping file $path contains multiple outer classes")
+ }
+ if (expectedOuterClass != null && owners.isNotEmpty() && owners.single() != expectedOuterClass) {
+ throw IOException("Enigma mapping file $path contains ${owners.single()}, not $expectedOuterClass")
+ }
+ return tree
+ }
+
+ @Throws(IOException::class)
+ private fun writeTree(tree: MemoryMappingTree): String {
+ val meaningful = newTree()
+ for (clazz in tree.classes) {
+ if (isMeaningful(clazz)) {
+ meaningful.addClass(clazz)
+ }
+ }
+ if (meaningful.classes.isEmpty()) {
+ return ""
+ }
+
+ val output = StringWriter()
+ // The same as annotater's own formatting
+ EnigmaFileWriter(output).use { writer ->
+ meaningful.accept(EmptyElementFilter(writer), VisitOrder.createByName())
+ }
+ return output.toString()
+ }
+
+ private fun setInTree(tree: MemoryMappingTree, target: AnnotaterJavadocTarget, comment: String?) {
+ if (comment == null) {
+ val clazz = tree.getClass(target.owner) ?: return
+ when (target) {
+ is AnnotaterJavadocTarget.ClassTarget -> clazz.comment = null
+ is AnnotaterJavadocTarget.FieldTarget -> {
+ val field = clazz.getField(target.name, target.descriptor)
+ if (field != null) {
+ field.comment = null
+ if (!hasContent(field)) {
+ clazz.removeField(target.name, target.descriptor)
+ }
+ }
+ }
+
+ is AnnotaterJavadocTarget.MethodTarget -> {
+ val method = clazz.getMethod(target.name, target.descriptor)
+ if (method != null) {
+ method.comment = null
+ if (!hasContent(method)) {
+ clazz.removeMethod(target.name, target.descriptor)
+ }
+ }
+ }
+ }
+ if (!isMeaningful(clazz)) {
+ tree.removeClass(clazz.srcName)
+ }
+ return
+ }
+
+ tree.visitClass(target.owner)
+ tree.visitElementContent(MappedElementKind.CLASS)
+ when (target) {
+ is AnnotaterJavadocTarget.ClassTarget -> tree.visitComment(MappedElementKind.CLASS, comment)
+ is AnnotaterJavadocTarget.FieldTarget -> {
+ tree.visitField(target.name, target.descriptor)
+ tree.visitElementContent(MappedElementKind.FIELD)
+ tree.visitComment(MappedElementKind.FIELD, comment)
+ }
+
+ is AnnotaterJavadocTarget.MethodTarget -> {
+ tree.visitMethod(target.name, target.descriptor)
+ tree.visitElementContent(MappedElementKind.METHOD)
+ tree.visitComment(MappedElementKind.METHOD, comment)
+ }
+ }
+ tree.visitEnd()
+ }
+
+ private fun newTree(): MemoryMappingTree {
+ val tree = MemoryMappingTree()
+ tree.visitNamespaces(MappingUtil.NS_SOURCE_FALLBACK, listOf(MappingUtil.NS_TARGET_FALLBACK))
+ tree.visitEnd()
+ return tree
+ }
+
+ private fun copyTree(tree: MemoryMappingTree): MemoryMappingTree {
+ if (tree.srcNamespace == null) {
+ return newTree()
+ }
+ val copy = MemoryMappingTree()
+ tree.accept(copy)
+ return copy
+ }
+
+ private fun withoutMethodVariables(next: MappingVisitor): MappingVisitor {
+ return object : ForwardingMappingVisitor(next) {
+ override fun visitMethodArg(argPosition: Int, lvIndex: Int, srcName: String?) = false
+
+ override fun visitMethodVar(
+ lvtRowIndex: Int,
+ lvIndex: Int,
+ startOpIdx: Int,
+ endOpIdx: Int,
+ srcName: String?,
+ ) = false
+ }
+ }
+
+ private fun isMeaningful(clazz: MappingTree.ClassMapping): Boolean {
+ return hasContent(clazz) || clazz.fields.any(::hasContent) || clazz.methods.any(::hasContent)
+ }
+
+ private fun hasContent(element: MappingTree.ElementMapping): Boolean {
+ if (element.comment != null) {
+ return true
+ }
+ return (0 until element.tree.maxNamespaceId).any { element.getDstName(it) != null }
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt
new file mode 100644
index 000000000..1fd220c6c
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt
@@ -0,0 +1,133 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.util.descriptor
+import com.demonwav.mcdev.util.internalName
+import com.intellij.psi.PsiAnonymousClass
+import com.intellij.psi.PsiClass
+import com.intellij.psi.PsiCompiledElement
+import com.intellij.psi.PsiElement
+import com.intellij.psi.PsiField
+import com.intellij.psi.PsiJavaFile
+import com.intellij.psi.PsiMember
+import com.intellij.psi.PsiMethod
+import com.intellij.psi.PsiTypeParameter
+import com.intellij.psi.SyntaxTraverser
+import com.intellij.psi.impl.compiled.ClsElementImpl
+
+/**
+ * A class, field or method that can have a Javadoc comment in annotater. All names are internal (slash separated)
+ * names, and members are identified by their name and JVM descriptor.
+ */
+sealed class AnnotaterJavadocTarget {
+ abstract val owner: String
+
+ /**
+ * The top level class whose `.mapping` file stores this target.
+ */
+ val outerClass: String
+ get() = outerClassName(owner)
+
+ data class ClassTarget(val internalName: String) : AnnotaterJavadocTarget() {
+ override val owner get() = internalName
+ }
+
+ data class FieldTarget(
+ override val owner: String,
+ val name: String,
+ val descriptor: String,
+ ) : AnnotaterJavadocTarget()
+
+ data class MethodTarget(
+ override val owner: String,
+ val name: String,
+ val descriptor: String,
+ ) : AnnotaterJavadocTarget()
+
+ companion object {
+ /**
+ * The target documented by [element], which may be an element of a class file's decompiled text.
+ */
+ fun fromPsi(element: PsiElement): AnnotaterJavadocTarget? {
+ // Decompiled text is generated from the compiled class, which is more reliable to get the target of
+ val compiled = element.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: element
+ if (compiled !is PsiMember || !isDocumentable(compiled)) {
+ return null
+ }
+ return when (compiled) {
+ is PsiClass -> ClassTarget(compiled.internalName ?: return null)
+ is PsiField -> {
+ val owner = compiled.containingClass?.internalName ?: return null
+ FieldTarget(owner, compiled.name, compiled.descriptor ?: return null)
+ }
+
+ is PsiMethod -> {
+ val owner = compiled.containingClass?.internalName ?: return null
+ MethodTarget(owner, compiled.internalName, compiled.descriptor ?: return null)
+ }
+
+ else -> null
+ }
+ }
+
+ /**
+ * The classes, fields and methods declared in the source of [file] in text order, including those in local
+ * and anonymous classes, but not members that don't appear in the source, such as the implicit methods of
+ * records. For a class file, these are the declarations in its decompiled text.
+ */
+ fun declarations(file: PsiJavaFile): List {
+ val source = (if (file is PsiCompiledElement) file.mirror else file) as? PsiJavaFile ?: return emptyList()
+ return SyntaxTraverser.psiTraverser(source)
+ .filter(PsiMember::class.java)
+ .filter(::isDocumentable)
+ .toList()
+ }
+
+ /**
+ * Whether [member] is a kind of declaration that can be documented: a class, field or method, but not a type
+ * parameter or anonymous class (including enum constant bodies), which are also [PsiClass]es.
+ */
+ private fun isDocumentable(member: PsiMember): Boolean = when (member) {
+ is PsiTypeParameter, is PsiAnonymousClass -> false
+ is PsiClass, is PsiField, is PsiMethod -> true
+ else -> false
+ }
+
+ /**
+ * Returns the top level class of a nested class name, treating `$` directly after a package separator or at
+ * the start of a name as part of the class name, matching mcsrc.
+ */
+ fun outerClassName(name: String): String {
+ var start = 0
+ while (true) {
+ val separator = name.indexOf('$', start)
+ if (separator < 0) {
+ return name
+ }
+ if (separator > 0 && name[separator - 1] != '/') {
+ return name.substring(0, separator)
+ }
+ start = separator + 1
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt
new file mode 100644
index 000000000..2c5fb89d9
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt
@@ -0,0 +1,90 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.intellij.codeInspection.LocalInspectionTool
+import com.intellij.codeInspection.LocalQuickFix
+import com.intellij.codeInspection.ProblemDescriptor
+import com.intellij.codeInspection.ProblemsHolder
+import com.intellij.openapi.project.Project
+import com.intellij.psi.JavaElementVisitor
+import com.intellij.psi.PsiClass
+import com.intellij.psi.PsiDocumentManager
+import com.intellij.psi.PsiElementVisitor
+import com.intellij.psi.PsiJavaCodeReferenceElement
+import com.intellij.psi.javadoc.PsiDocComment
+import com.intellij.psi.util.PsiTreeUtil
+
+/**
+ * Annotater requires classes in Javadoc to be referenced by their fully qualified name.
+ */
+class AnnotaterJavadocUnqualifiedReferenceInspection : LocalInspectionTool() {
+ override fun getStaticDescription() = MCDevBundle("fabric.annotater.inspection.unqualified.description")
+
+ override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
+ if (AnnotaterJavadocVirtualFile.of(holder.file) == null) {
+ return PsiElementVisitor.EMPTY_VISITOR
+ }
+ return object : JavaElementVisitor() {
+ override fun visitReferenceElement(reference: PsiJavaCodeReferenceElement) {
+ // Only check whole references, not their qualifiers
+ if (reference.parent is PsiJavaCodeReferenceElement) {
+ return
+ }
+ if (PsiTreeUtil.getParentOfType(reference, PsiDocComment::class.java) == null) {
+ return
+ }
+ val qualifiedName = Util.qualifiedNameIfUnqualified(reference) ?: return
+ holder.registerProblem(
+ reference,
+ MCDevBundle("fabric.annotater.inspection.unqualified.problem", qualifiedName),
+ QualifyFix(qualifiedName),
+ )
+ }
+ }
+ }
+
+ private class QualifyFix(private val qualifiedName: String) : LocalQuickFix {
+ override fun getName() = MCDevBundle("fabric.annotater.inspection.unqualified.fix", qualifiedName)
+
+ override fun getFamilyName() = MCDevBundle("fabric.annotater.inspection.unqualified.fix.family")
+
+ override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
+ val reference = descriptor.psiElement as? PsiJavaCodeReferenceElement ?: return
+ val file = reference.containingFile ?: return
+ val documentManager = PsiDocumentManager.getInstance(project)
+ val document = documentManager.getDocument(file) ?: return
+ val range = reference.textRange
+ document.replaceString(range.startOffset, range.endOffset, qualifiedName)
+ documentManager.commitDocument(document)
+ }
+ }
+
+ private object Util {
+ fun qualifiedNameIfUnqualified(reference: PsiJavaCodeReferenceElement): String? {
+ val resolved = reference.resolve() as? PsiClass ?: return null
+ val qualifiedName = resolved.qualifiedName ?: return null
+ val written = reference.text.filterNot { it.isWhitespace() }
+ return qualifiedName.takeIf { written != it }
+ }
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt
new file mode 100644
index 000000000..26eec473c
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt
@@ -0,0 +1,31 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.intellij.openapi.util.Condition
+import com.intellij.psi.PsiElement
+
+/**
+ * Prevents renaming declarations in annotater Javadoc editors, which would change the read-only copied source code.
+ */
+class AnnotaterJavadocVetoRenameCondition : Condition {
+ override fun value(element: PsiElement): Boolean = AnnotaterJavadocVirtualFile.of(element.containingFile) != null
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt
new file mode 100644
index 000000000..c165c9f10
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt
@@ -0,0 +1,52 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout
+import com.intellij.ide.highlighter.JavaFileType
+import com.intellij.psi.PsiFile
+import com.intellij.psi.util.PsiUtil
+import com.intellij.testFramework.LightVirtualFile
+
+/**
+ * The copy of a Minecraft source file that an annotater Javadoc is edited in. It's never opened as a tab, but shown
+ * through an [AnnotaterJavadocInlineEditor]'s inline editor, which only shows the Javadoc of [target].
+ */
+class AnnotaterJavadocVirtualFile(
+ val checkout: AnnotaterCheckout,
+ val copy: AnnotaterJavadocSourceCopy,
+ val target: AnnotaterJavadocTarget,
+) : LightVirtualFile(copy.fileName, JavaFileType.INSTANCE, copy.text) {
+ @Volatile
+ var error: String? = null
+
+ val outerClass: String
+ get() = copy.outerClass
+
+ init {
+ putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, copy.languageLevel)
+ }
+
+ companion object {
+ fun of(file: PsiFile?): AnnotaterJavadocVirtualFile? =
+ file?.viewProvider?.virtualFile as? AnnotaterJavadocVirtualFile
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt
new file mode 100644
index 000000000..062303de3
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt
@@ -0,0 +1,52 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.GeneralAssets
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.actionSystem.CommonDataKeys
+
+class EditAnnotaterJavadocAction : AnAction() {
+ override fun getActionUpdateThread() = ActionUpdateThread.BGT
+
+ override fun update(e: AnActionEvent) {
+ e.presentation.text = MCDevBundle("fabric.annotater.javadoc.edit")
+ e.presentation.icon = GeneralAssets.TINY_POTATO
+ val editor = e.getData(CommonDataKeys.EDITOR)
+ val file = e.getData(CommonDataKeys.PSI_FILE)
+ val project = e.project
+ e.presentation.isEnabledAndVisible = project != null &&
+ AnnotaterJavadocEditing.isOffered(project) &&
+ editor != null &&
+ file != null &&
+ AnnotaterJavadocEditing.findTarget(editor, file) != null
+ }
+
+ override fun actionPerformed(e: AnActionEvent) {
+ val project = e.project ?: return
+ val editor = e.getData(CommonDataKeys.EDITOR) ?: return
+ val file = e.getData(CommonDataKeys.PSI_FILE) ?: return
+ AnnotaterJavadocEditing.open(project, editor, file)
+ }
+}
diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt
new file mode 100644
index 000000000..ccfab85b3
--- /dev/null
+++ b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt
@@ -0,0 +1,53 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater.javadoc
+
+import com.demonwav.mcdev.asset.MCDevBundle
+import com.intellij.codeInsight.intention.IntentionAction
+import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.project.Project
+import com.intellij.psi.PsiFile
+
+class EditAnnotaterJavadocIntention : IntentionAction {
+ override fun getText() = MCDevBundle("fabric.annotater.javadoc.edit")
+
+ override fun getFamilyName() = text
+
+ override fun startInWriteAction() = false
+
+ override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean {
+ if (editor == null || file == null || !AnnotaterJavadocEditing.isOffered(project)) {
+ return false
+ }
+ return AnnotaterJavadocEditing.findTarget(editor, file) != null
+ }
+
+ override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
+ if (editor == null || file == null) {
+ return
+ }
+ AnnotaterJavadocEditing.open(project, editor, file)
+ }
+
+ override fun generatePreview(project: Project, editor: Editor, file: PsiFile): IntentionPreviewInfo =
+ IntentionPreviewInfo.EMPTY
+}
diff --git a/src/main/kotlin/util/McdevReflection.kt b/src/main/kotlin/util/McdevReflection.kt
index 1dd20f251..6f8504ef2 100644
--- a/src/main/kotlin/util/McdevReflection.kt
+++ b/src/main/kotlin/util/McdevReflection.kt
@@ -22,10 +22,12 @@ package com.demonwav.mcdev.util
import com.intellij.codeInsight.generation.ClassMember
import com.intellij.codeInsight.generation.GenerateMembersHandlerBase
+import com.intellij.codeInspection.LocalQuickFix
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.openapi.updateSettings.impl.PluginDownloader
import com.intellij.psi.PsiClass
+import java.lang.reflect.Constructor
import java.lang.reflect.Field
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
@@ -63,6 +65,14 @@ object McdevReflection {
Array::class.java,
)
+ @JvmStatic
+ val CLASS_MARKDOWN_DOCUMENTATION_COMMENTS_MIGRATION_FIX: Class<*> =
+ Class.forName("com.siyeh.ig.migration.MarkdownDocumentationCommentsMigrationInspection\$MarkdownDocumentationCommentsMigrationFix")
+
+ @JvmStatic
+ val CONSTRUCTOR_MARKDOWN_DOCUMENTATION_COMMENTS_MIGRATION_FIX: Constructor =
+ CLASS_MARKDOWN_DOCUMENTATION_COMMENTS_MIGRATION_FIX.getDeclaredConstructorEx()
+
@JvmStatic
val FIELD_PLUGIN_DOWNLOADER_MY_FILE: Field =
PluginDownloader::class.java.getDeclaredFieldEx("myFile", Path::class.java)
@@ -79,6 +89,18 @@ object McdevReflection {
return field
}
+ /**
+ * Like [Class.getDeclaredConstructor], but makes the constructor accessible.
+ */
+ private inline fun Class<*>.getDeclaredConstructorEx(vararg params: Class<*>): Constructor {
+ check(T::class.java.isAssignableFrom(this)) {
+ "Class ${this.name} is not a subclass of ${T::class.java.name}"
+ }
+ val constructor = this.asSubclass(T::class.java).getDeclaredConstructor(*params)
+ constructor.isAccessible = true
+ return constructor
+ }
+
/**
* Like [Class.getDeclaredMethod], but makes the method accessible and checks that it has the expected [returnType].
*/
@@ -91,6 +113,18 @@ object McdevReflection {
return method
}
+ /**
+ * Like [Constructor.newInstance], but rethrows the exception thrown by the invoked constructor rather than wrapping
+ * it in an [InvocationTargetException].
+ */
+ fun Constructor.newInstanceUnwrap(vararg args: Any?): T {
+ try {
+ return newInstance(*args)
+ } catch (e: InvocationTargetException) {
+ throw e.cause ?: e
+ }
+ }
+
/**
* Like [Method.invoke], but rethrows the exception thrown by the invoked method rather than wrapping it in an
* [InvocationTargetException].
diff --git a/src/main/resources/META-INF/mcdev-annotater-git.xml b/src/main/resources/META-INF/mcdev-annotater-git.xml
new file mode 100644
index 000000000..a7c1d86c9
--- /dev/null
+++ b/src/main/resources/META-INF/mcdev-annotater-git.xml
@@ -0,0 +1,28 @@
+
+
+
+ org.jetbrains.plugins.github
+
+
+
+
+
diff --git a/src/main/resources/META-INF/mcdev-annotater-github.xml b/src/main/resources/META-INF/mcdev-annotater-github.xml
new file mode 100644
index 000000000..88574160d
--- /dev/null
+++ b/src/main/resources/META-INF/mcdev-annotater-github.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index c51fe71ce..2001daf4d 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -34,6 +34,7 @@
org.jetbrains.kotlin
org.toml.lang
org.jetbrains.plugins.yaml
+ Git4Idea
com.demonwav.minecraft-dev
Minecraft Development
@@ -405,6 +406,10 @@
id="Settings.Minecraft"
groupId="language"
instance="com.demonwav.mcdev.MinecraftConfigurable"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ JAVA
+ com.demonwav.mcdev.platform.fabric.annotater.javadoc.EditAnnotaterJavadocIntention
+ Minecraft
+ EditAnnotaterJavadocIntention
+
@@ -1050,6 +1079,20 @@
+
+
+
+
@@ -1768,6 +1815,12 @@
text="Minecraft Class" description="Create skeleton classes used in Minecraft Mods">
+
+
+
diff --git a/src/main/resources/assets/icons/general/tiny_potato.LICENSE.txt b/src/main/resources/assets/icons/general/tiny_potato.LICENSE.txt
new file mode 100644
index 000000000..8765954e5
--- /dev/null
+++ b/src/main/resources/assets/icons/general/tiny_potato.LICENSE.txt
@@ -0,0 +1,3 @@
+tiny_potato.png, tiny_potato@2x.png, tiny_potato_large.png and tiny_potato_large@2x.png are downscaled from
+https://github.com/FabricMC/community/blob/main/media/tater-wall/png/potato.png by FabricMC, licensed under the
+Creative Commons Attribution 4.0 International license (https://creativecommons.org/licenses/by/4.0/).
diff --git a/src/main/resources/assets/icons/general/tiny_potato.png b/src/main/resources/assets/icons/general/tiny_potato.png
new file mode 100644
index 000000000..41f799e85
Binary files /dev/null and b/src/main/resources/assets/icons/general/tiny_potato.png differ
diff --git a/src/main/resources/assets/icons/general/tiny_potato@2x.png b/src/main/resources/assets/icons/general/tiny_potato@2x.png
new file mode 100644
index 000000000..596222373
Binary files /dev/null and b/src/main/resources/assets/icons/general/tiny_potato@2x.png differ
diff --git a/src/main/resources/assets/icons/general/tiny_potato_large.png b/src/main/resources/assets/icons/general/tiny_potato_large.png
new file mode 100644
index 000000000..596222373
Binary files /dev/null and b/src/main/resources/assets/icons/general/tiny_potato_large.png differ
diff --git a/src/main/resources/assets/icons/general/tiny_potato_large@2x.png b/src/main/resources/assets/icons/general/tiny_potato_large@2x.png
new file mode 100644
index 000000000..422a6e0ad
Binary files /dev/null and b/src/main/resources/assets/icons/general/tiny_potato_large@2x.png differ
diff --git a/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/after.java.template b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/after.java.template
new file mode 100644
index 000000000..07048b40a
--- /dev/null
+++ b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/after.java.template
@@ -0,0 +1,6 @@
+/// Clamps a value between a minimum and a maximum.
+///
+/// @return the clamped value
+public static int clamp(int value, int min, int max) {
+ return Math.min(Math.max(value, min), max);
+}
diff --git a/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/before.java.template b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/before.java.template
new file mode 100644
index 000000000..4d9aed0f1
--- /dev/null
+++ b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/before.java.template
@@ -0,0 +1,3 @@
+public static int clamp(int value, int min, int max) {
+ return Math.min(Math.max(value, min), max);
+}
diff --git a/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/description.html b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/description.html
new file mode 100644
index 000000000..af08b791f
--- /dev/null
+++ b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/description.html
@@ -0,0 +1,25 @@
+
+
+
+
+Opens an editor for the annotater Javadoc of a Minecraft class, field or method. Annotater Javadocs are written as Markdown documentation comments (///) and saved to your annotater checkout.
+
+
diff --git a/src/main/resources/messages/MinecraftDevelopment.properties b/src/main/resources/messages/MinecraftDevelopment.properties
index e3c9242b9..5082eda53 100644
--- a/src/main/resources/messages/MinecraftDevelopment.properties
+++ b/src/main/resources/messages/MinecraftDevelopment.properties
@@ -400,3 +400,110 @@ live.template.bukkit.event_handler.description=Create an event handler
update.outdated_ide.title=IDE version no longer supported
update.outdated_ide.content=Minecraft Development has dropped support for {0}. To receive further updates to Minecraft Development, please update your IDE.
+
+
+fabric.annotater.settings.display_name=Annotater
+fabric.annotater.settings.title=Annotater
+fabric.annotater.settings.enabled=Enable annotater
+fabric.annotater.settings.enabled.comment=Contribute to annotater, Fabric's Minecraft documentation, from within the IDE.
+fabric.annotater.settings.guidelines=Please follow annotater''s {0} when writing Javadocs and opening pull requests.
+fabric.annotater.settings.guidelines.link=contribution guidelines
+fabric.annotater.settings.guidelines.link.version=contribution guidelines for {0}
+fabric.annotater.settings.show_javadocs=Show annotater Javadocs in code
+fabric.annotater.settings.managed=Clone annotater automatically
+fabric.annotater.settings.managed_root=Clone into\:
+fabric.annotater.settings.clone_now=Clone or Update Annotater
+fabric.annotater.settings.clone_now.version=Minecraft version to clone annotater for\:
+fabric.annotater.settings.clone_now.no_versions=No Minecraft versions could be found in the open projects. Refresh the Gradle project, or set the Minecraft version in the Minecraft module settings.
+fabric.annotater.settings.no_git=The Git plugin is required to clone annotater.
+fabric.annotater.settings.custom=Use an existing annotater directory\:
+fabric.annotater.settings.custom.comment=Commit, push and open pull requests yourself. MinecraftDev will not run git in this directory.
+fabric.annotater.settings.custom.version_mismatch=This annotater directory is for Minecraft {0}, but the open projects use Minecraft {1}.
+
+fabric.annotater.unavailable=Annotater unavailable
+fabric.annotater.configure=Configure Annotater
+fabric.annotater.clone=Clone annotater for {0}
+fabric.annotater.cloned=Annotater for {0} is ready
+fabric.annotater.cloned.content=Annotater is checked out in {0}.
+fabric.annotater.clone_failed=Failed to clone annotater
+fabric.annotater.cloning=Cloning annotater for {0}
+fabric.annotater.cloning.clone=Cloning annotater...
+fabric.annotater.cloning.fetch=Fetching annotater...
+fabric.annotater.cloning.worktree=Checking out annotater...
+fabric.annotater.no_branch=Annotater has no branch for Minecraft {0}.
+fabric.annotater.invalid_after_clone=The annotater checkout at {0} is not valid.
+fabric.annotater.uncommitted_changes=The annotater checkout has uncommitted changes.
+fabric.annotater.preparing=Preparing annotater checkout
+fabric.annotater.preparing.failed=Failed to prepare the annotater checkout
+fabric.annotater.create_pull_request=Create Pull Request...
+fabric.annotater.update_pull_request=Update Pull Request...
+fabric.annotater.dropdown={0} \u25BE
+fabric.annotater.menu=Annotater
+fabric.annotater.contribution.tooltip=Switch to another contribution
+fabric.annotater.contribution.switch=Switch Contribution
+fabric.annotater.contribution.new=New contribution
+fabric.annotater.contribution.switching=Switching annotater contribution
+fabric.annotater.contribution.failed=Failed to switch annotater contribution
+fabric.annotater.contribution.save_failed=Fix the errors in the open annotater editors before switching contribution.
+fabric.annotater.contribution.commit=The changes to "{0}" will be committed before switching. Commit message\:
+fabric.annotater.contribution.delete=Delete Contribution
+fabric.annotater.contribution.delete.title=Delete Contribution
+fabric.annotater.contribution.delete.ok=Delete
+fabric.annotater.contribution.delete.confirm=Delete the contribution "{0}"? It is only deleted locally. Any branch pushed to GitHub and any pull request for it are kept.
+fabric.annotater.contribution.delete.confirm.current=This contribution is checked out, so its uncommitted changes will be lost.
+fabric.annotater.contribution.deleting=Deleting annotater contribution
+fabric.annotater.contribution.deleted=Annotater contribution deleted
+fabric.annotater.contribution.deleted.content=Deleted the contribution "{0}".
+fabric.annotater.contribution.delete.failed=Failed to delete annotater contribution
+fabric.annotater.status.disabled=Annotater is disabled in the MinecraftDev settings.
+fabric.annotater.status.unknown_version=The Minecraft version of this project is unknown.
+fabric.annotater.status.not_cloned=Annotater has not been cloned for Minecraft {0} yet.
+fabric.annotater.status.not_cloned_no_git=Annotater has not been cloned for Minecraft {0}. Install the Git plugin, or select an existing annotater directory.
+fabric.annotater.status.version_mismatch=The selected annotater directory is for Minecraft {0}, but this project uses Minecraft {1}.
+fabric.annotater.status.invalid=Invalid annotater directory\: {0}
+
+fabric.annotater.javadoc.edit=Edit Annotater Javadoc
+fabric.annotater.javadoc.open_failed=Failed to open annotater Javadoc
+fabric.annotater.banner=Annotater Javadocs for Minecraft {0} from {1}. Changes are saved automatically.
+fabric.annotater.javadoc.finish=Finish editing
+fabric.annotater.javadoc.save_failed=Failed to save annotater Javadoc
+fabric.annotater.javadoc.error.not_markdown=Annotater only supports Markdown Javadoc comments (///). Changes were not saved.
+fabric.annotater.javadoc.error.plain_comment=Only Markdown Javadoc comments (///) can be saved to annotater. Changes were not saved.
+fabric.annotater.javadoc.error.structure_changed=The declarations in the copy of the source file changed. Changes were not saved.
+fabric.annotater.javadoc.error.io=Failed to save annotater Javadocs\: {0}
+
+fabric.annotater.inspection.unqualified.description=Reports classes referenced in annotater Javadocs without their fully qualified name, which annotater requires.
+fabric.annotater.inspection.unqualified.problem=Annotater requires fully qualified class names, use ''{0}''
+fabric.annotater.inspection.unqualified.fix=Replace with ''{0}''
+fabric.annotater.inspection.unqualified.fix.family=Use fully qualified class name
+fabric.annotater.inspection.comment.description=Reports comments in annotater Javadoc editors that are not Markdown Javadoc comments, which annotater does not support.
+
+fabric.annotater.pr.checking=Checking annotater changes
+fabric.annotater.pr.nothing=Nothing to submit
+fabric.annotater.pr.nothing.content=There are no annotater Javadoc changes for Minecraft {0} to submit.
+fabric.annotater.pr.no_account=Log in to a GitHub.com account in the GitHub settings to create pull requests.
+fabric.annotater.pr.failed=Failed to create annotater pull request
+fabric.annotater.pr.no_base_branch={0} has no {1} branch to open a pull request against.
+fabric.annotater.pr.creating=Creating annotater pull request
+fabric.annotater.pr.updating=Updating annotater pull request
+fabric.annotater.pr.committing=Committing changes...
+fabric.annotater.pr.forking=Forking annotater...
+fabric.annotater.pr.pushing=Pushing changes...
+fabric.annotater.pr.opening=Opening pull request...
+fabric.annotater.pr.created=Annotater pull request created
+fabric.annotater.pr.updated=Annotater pull request updated
+fabric.annotater.pr.open=Open in Browser
+fabric.annotater.pr.start_new=Start New Contribution
+fabric.annotater.pr.dialog.ok=Create Pull Request
+fabric.annotater.pr.dialog.update=Update Pull Request
+fabric.annotater.pr.dialog.account=GitHub account\:
+fabric.annotater.pr.dialog.title=Title\:
+fabric.annotater.pr.dialog.description=Description\:
+fabric.annotater.pr.dialog.commit_message=Commit message\:
+fabric.annotater.pr.dialog.changes=Changed files\:
{0}
+fabric.annotater.pr.dialog.check_build=Check the build first
+fabric.annotater.pr.dialog.check_build.comment=Runs gradlew check in the annotater checkout. May take a minute or two.
+fabric.annotater.pr.check_build.progress=Checking the annotater build
+fabric.annotater.pr.check_build.failed=Annotater Build Check Failed
+fabric.annotater.pr.check_build.failed.message=The annotater build check failed with the following error log.
+fabric.annotater.pr.check_build.submit_anyway=Submit Anyway
diff --git a/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt
new file mode 100644
index 000000000..f8f721f2a
--- /dev/null
+++ b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt
@@ -0,0 +1,441 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.demonwav.mcdev.framework.BaseMinecraftTest
+import com.demonwav.mcdev.framework.EdtInterceptor
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocInlineEditor
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocIntentionActionFilter
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocRendering
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocSourceCopy
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarget
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocUnqualifiedReferenceInspection
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocVirtualFile
+import com.intellij.codeInsight.intention.IntentionAction
+import com.intellij.codeInsight.lookup.Lookup
+import com.intellij.ide.DataManager
+import com.intellij.ide.impl.HeadlessDataManager
+import com.intellij.lang.annotation.HighlightSeverity
+import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
+import com.intellij.openapi.command.WriteCommandAction
+import com.intellij.openapi.command.undo.UndoManager
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.editor.ex.EditorEx
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.fileEditor.TextEditor
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Disposer
+import com.intellij.openapi.util.TextRange
+import com.intellij.psi.JavaPsiFacade
+import com.intellij.psi.PsiClass
+import com.intellij.psi.PsiFile
+import com.intellij.psi.PsiMember
+import com.intellij.psi.PsiMethod
+import com.intellij.psi.search.GlobalSearchScope
+import java.nio.file.Path
+import java.util.concurrent.TimeUnit
+import kotlin.io.path.readText
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertInstanceOf
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.io.TempDir
+
+@ExtendWith(EdtInterceptor::class)
+@DisplayName("Annotater Javadoc Editor Tests")
+class AnnotaterJavadocEditorTest : BaseMinecraftTest() {
+ @TempDir
+ lateinit var root: Path
+
+ private lateinit var checkout: AnnotaterCheckout
+
+ private val clamp = AnnotaterJavadocTarget.MethodTarget("net/minecraft/util/Mth", "clamp", "(III)I")
+
+ @BeforeEach
+ fun setUpProject() {
+ checkout = AnnotaterCheckout(root, AnnotaterConfig("26.3", "mappings", null), managed = false)
+ buildProject {
+ java(
+ "net/minecraft/util/Mth.java",
+ """
+ package net.minecraft.util;
+
+ /** Existing class docs. */
+ public class Mth {
+ /** Existing method docs. */
+ public static int clamp(int value, int min, int max) {
+ // Not clamped yet
+ return value;
+ }
+
+ public static final float PI = 3.14F;
+ }
+ """.trimIndent(),
+ )
+ java(
+ "net/minecraft/util/Ticker.java",
+ """
+ package net.minecraft.util;
+
+ public class Ticker {
+ public void tick() {
+ class Step {
+ int count;
+ }
+ Runnable runnable = new Runnable() {
+ @Override
+ public void run() {
+ }
+ };
+ }
+ }
+ """.trimIndent(),
+ )
+ java(
+ "net/minecraft/world/level/Level.java",
+ """
+ package net.minecraft.world.level;
+
+ public class Level {
+ }
+ """.trimIndent(),
+ )
+ }
+ }
+
+ private fun findMember(className: String, name: String? = null): PsiMember {
+ val clazz = JavaPsiFacade.getInstance(project).findClass(className, GlobalSearchScope.allScope(project))!!
+ return when (name) {
+ null -> clazz
+ else -> clazz.findMethodsByName(name, false).firstOrNull() ?: clazz.findFieldByName(name, false)!!
+ }
+ }
+
+ private val pi = AnnotaterJavadocTarget.FieldTarget("net/minecraft/util/Mth", "PI", "F")
+
+ private fun clampCopy(javadoc: String?, placeholderFor: AnnotaterJavadocTarget? = null) =
+ AnnotaterJavadocSourceCopy.create(findMember("net.minecraft.util.Mth", "clamp"), placeholderFor) {
+ if (it == clamp) javadoc else null
+ }!!
+
+ @Test
+ @DisplayName("Source copy replaces the existing Javadocs with Markdown annotater Javadocs")
+ fun sourceCopyText() {
+ val copy = clampCopy("Clamps a value.\n\n@param value the value")
+ assertEquals(
+ """
+ package net.minecraft.util;
+
+ public class Mth {
+ /// Clamps a value.
+ ///
+ /// @param value the value
+ public static int clamp(int value, int min, int max) {
+ // Not clamped yet
+ return value;
+ }
+
+ public static final float PI = 3.14F;
+ }
+ """.trimIndent(),
+ copy.text,
+ )
+ assertEquals("Mth.java", copy.fileName)
+ assertEquals(listOf(AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Mth"), clamp, pi), copy.targets)
+ }
+
+ @Test
+ @DisplayName("Local classes and members of local and anonymous classes can be documented")
+ fun localAndAnonymousClasses() {
+ val copy = AnnotaterJavadocSourceCopy.create(findMember("net.minecraft.util.Ticker")) { null }!!
+ assertEquals(
+ listOf(
+ AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Ticker"),
+ AnnotaterJavadocTarget.MethodTarget("net/minecraft/util/Ticker", "tick", "()V"),
+ AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Ticker\$1Step"),
+ AnnotaterJavadocTarget.FieldTarget("net/minecraft/util/Ticker\$1Step", "count", "I"),
+ AnnotaterJavadocTarget.MethodTarget("net/minecraft/util/Ticker\$1", "run", "()V"),
+ ),
+ // Type parameters and anonymous classes are also PsiClasses, but aren't declarations that can be documented
+ copy.targets,
+ )
+ assertEquals("net/minecraft/util/Ticker", copy.outerClass)
+ }
+
+ @Test
+ @DisplayName("Source copy adds a placeholder Javadoc to the edited member")
+ fun sourceCopyPlaceholder() {
+ val copy = clampCopy(null, placeholderFor = clamp)
+ assertTrue(copy.text.contains("public class Mth {\n /// \n public static int clamp("), copy.text)
+ }
+
+ @Test
+ @DisplayName("Javadocs are extracted from Markdown doc comments")
+ fun extract() {
+ val copy = clampCopy("Clamps a value.\n\n@param value the value")
+ val file = copy.createPsiFile(project)!!
+ val method = copy.memberOf(file, clamp) as PsiMethod
+ assertTrue(method.docComment!!.isMarkdownComment)
+ assertEquals(clamp, copy.targetOf(file, method))
+ assertEquals(
+ AnnotaterJavadocSourceCopy.ExtractResult.Success(
+ mapOf(
+ AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Mth") to null,
+ pi to null,
+ clamp to "Clamps a value.\n\n@param value the value",
+ ),
+ ),
+ // The comment in the method body is part of the original source
+ copy.extractJavadocs(file) { it.substring(file.text) == "// Not clamped yet" },
+ )
+ }
+
+ @Test
+ @DisplayName("Traditional Javadoc comments are rejected")
+ fun rejectTraditionalJavadoc() {
+ val copy = clampCopy("Clamps a value.")
+ val changed = AnnotaterJavadocSourceCopy(
+ copy.fileName,
+ copy.text.replace("/// Clamps a value.", "/** Clamps a value. */"),
+ copy.targets,
+ copy.languageLevel,
+ )
+ val result = changed.extractJavadocs(changed.createPsiFile(project)!!)
+ assertInstanceOf(AnnotaterJavadocSourceCopy.ExtractResult.Error::class.java, result)
+ }
+
+ private fun startEditing(copy: AnnotaterJavadocSourceCopy, target: AnnotaterJavadocTarget) =
+ AnnotaterJavadocInlineEditor.start(
+ project,
+ AnnotaterJavadocVirtualFile(checkout, copy, target),
+ )!!
+
+ @Test
+ @DisplayName("Editing the Javadoc saves it to the annotater checkout")
+ fun save() {
+ val inlineEditor = startEditing(clampCopy(null, placeholderFor = clamp), clamp)
+ val document = FileDocumentManager.getInstance().getDocument(inlineEditor.file)!!
+ try {
+ WriteCommandAction.runWriteCommandAction(project) {
+ val offset = document.text.indexOf("/// ") + 4
+ document.insertString(offset, "Clamps a value.")
+ }
+ assertTrue(inlineEditor.save().get(10, TimeUnit.SECONDS))
+ assertEquals(
+ "CLASS net/minecraft/util/Mth\n\tMETHOD clamp (III)I\n\t\tCOMMENT Clamps a value.\n",
+ root.resolve("mappings/net/minecraft/util/Mth.mapping").readText(),
+ )
+ } finally {
+ Disposer.dispose(inlineEditor)
+ }
+ }
+
+ @Test
+ @DisplayName("Only the edited Javadoc is editable")
+ fun guards() {
+ val inlineEditor = startEditing(clampCopy("Clamps a value."), clamp)
+ val document = FileDocumentManager.getInstance().getDocument(inlineEditor.file)!!
+ try {
+ val text = document.text
+ fun guarded(start: Int, end: Int) = document.getRangeGuard(start, end) != null
+ val comment = text.indexOf("/// Clamps a value.")
+ val commentEnd = comment + "/// Clamps a value.".length
+ assertFalse(guarded(comment, commentEnd))
+ assertTrue(guarded(commentEnd, commentEnd + 1), "The line break after the comment is read-only")
+ assertTrue(guarded(text.indexOf("return value"), text.indexOf("return value") + 1))
+ assertTrue(guarded(text.indexOf("PI"), text.indexOf("PI") + 1))
+ } finally {
+ Disposer.dispose(inlineEditor)
+ }
+ }
+
+ @Test
+ @DisplayName("Saving doesn't overwrite Javadocs of other members that changed on disk")
+ fun saveOnlyEdited() {
+ checkout.javadocStore.setAll(mapOf(pi to "Pi."))
+ val copy = AnnotaterJavadocSourceCopy.create(findMember("net.minecraft.util.Mth", "clamp"), clamp) {
+ checkout.javadocStore.get(it)
+ }!!
+ val inlineEditor = startEditing(copy, clamp)
+ val document = FileDocumentManager.getInstance().getDocument(inlineEditor.file)!!
+ try {
+ checkout.javadocStore.setAll(mapOf(pi to "Changed on disk."))
+ WriteCommandAction.runWriteCommandAction(project) {
+ document.insertString(document.text.indexOf("/// \n public static int") + 4, "Clamps a value.")
+ }
+ assertTrue(inlineEditor.save().get(10, TimeUnit.SECONDS))
+ checkout.javadocStore.invalidate(clamp.outerClass)
+ assertEquals("Clamps a value.", checkout.javadocStore.get(clamp))
+ assertEquals("Changed on disk.", checkout.javadocStore.get(pi))
+ } finally {
+ Disposer.dispose(inlineEditor)
+ }
+ }
+
+ @Test
+ @DisplayName("The Javadoc is edited in an editor embedded in the class, which only shows the Javadoc")
+ fun inlineEditor() {
+ val method = findMember("net.minecraft.util.Mth", "clamp")
+ fixture.openFileInEditor(method.containingFile.virtualFile)
+ val host = fixture.editor as EditorEx
+ val inlineEditor = startEditing(clampCopy("Clamps a value.\n\n@param value the value"), clamp)
+ try {
+ inlineEditor.show(host, AnnotaterJavadocRendering.displayOffset(method))
+ val editor = inlineEditor.editor!!
+ val document = editor.document
+ // Only the 3 comment lines are visible
+ assertEquals(2, editor.offsetToVisualLine(document.textLength, false))
+ assertEquals(0, editor.offsetToVisualLine(0, false))
+ assertEquals(
+ document.text.indexOf("/// Clamps a value.") + "/// Clamps a value.".length,
+ editor.caretModel.offset,
+ )
+ assertEquals(1, host.inlayModel.getBlockElementsInRange(0, host.document.textLength).size)
+
+ // The end of the document is displayed at the end of the last comment line
+ editor.caretModel.moveToOffset(document.textLength)
+ val lastLine = document.text.indexOf("/// @param value the value") + "/// @param value the value".length
+ assertEquals(lastLine, editor.caretModel.offset)
+ editor.caretModel.moveToOffset(0)
+ val firstLine = document.getLineStartOffset(document.getLineNumber(document.text.indexOf("/// Clamps")))
+ assertEquals(firstLine, editor.caretModel.offset)
+
+ WriteCommandAction.runWriteCommandAction(project) {
+ document.insertString(lastLine, " Or not.")
+ }
+ val edited = document.text
+ // Undo uses the embedded editor, not the host editor it's embedded in
+ val dataManager = DataManager.getInstance() as HeadlessDataManager
+ HeadlessDataManager.fallbackToProductionDataManager(inlineEditor)
+ dataManager.setTestDataProvider(null, inlineEditor)
+ val fileEditor = DataManager.getInstance().getDataContext(editor.contentComponent)
+ .getData(PlatformCoreDataKeys.FILE_EDITOR) as TextEditor
+ assertSame(editor, fileEditor.editor)
+ val undoManager = UndoManager.getInstance(project)
+ assertTrue(undoManager.isUndoAvailable(fileEditor))
+ undoManager.undo(fileEditor)
+ assertFalse(document.text.contains("Or not."))
+ undoManager.redo(fileEditor)
+ assertEquals(edited, document.text)
+
+ assertTrue(inlineEditor.finish())
+ assertNull(AnnotaterJavadocInlineEditor.current(project))
+ assertTrue(host.inlayModel.getBlockElementsInRange(0, host.document.textLength).isEmpty())
+ } finally {
+ Disposer.dispose(inlineEditor)
+ }
+ }
+
+ @Test
+ @DisplayName("Editing can't be finished while the Javadoc can't be saved")
+ fun finishInvalid() {
+ val inlineEditor = startEditing(clampCopy("Clamps a value."), clamp)
+ val document = FileDocumentManager.getInstance().getDocument(inlineEditor.file)!!
+ try {
+ WriteCommandAction.runWriteCommandAction(project) {
+ val start = document.text.indexOf("/// Clamps a value.")
+ document.replaceString(start, start + "/// Clamps a value.".length, "/** Clamps a value. */")
+ }
+ assertFalse(inlineEditor.finish())
+ assertSame(inlineEditor, AnnotaterJavadocInlineEditor.current(project))
+ assertTrue(inlineEditor.finish(force = true))
+ assertNull(AnnotaterJavadocInlineEditor.current(project))
+ } finally {
+ Disposer.dispose(inlineEditor)
+ }
+ }
+
+ @Test
+ @DisplayName("Intentions are only offered in the editable Javadocs")
+ fun intentionFilter() {
+ fixture.openFileInEditor(AnnotaterJavadocVirtualFile(checkout, clampCopy("Clamps a value."), clamp))
+ val file = fixture.file
+ val text = fixture.editor.document.text
+ val intention = object : IntentionAction {
+ override fun getText() = "Test"
+ override fun getFamilyName() = text
+ override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?) = true
+ override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {}
+ override fun startInWriteAction() = true
+ }
+ val filter = AnnotaterJavadocIntentionActionFilter()
+ assertTrue(filter.accept(intention, file, text.indexOf("Clamps")))
+ assertFalse(filter.accept(intention, file, text.indexOf("return value")))
+ assertFalse(filter.accept(intention, file, text.indexOf("// Not clamped")))
+ assertFalse(filter.accept(intention, file, -1))
+ }
+
+ @Test
+ @DisplayName("Unqualified class references are reported and fixed")
+ fun unqualifiedReferences() {
+ val copy = clampCopy(
+ "See [Mth], [net.minecraft.util.Mth#clamp], [#clamp] and [String].\n\n@see net.minecraft.world.level.Level",
+ )
+ fixture.enableInspections(AnnotaterJavadocUnqualifiedReferenceInspection::class)
+ fixture.openFileInEditor(AnnotaterJavadocVirtualFile(checkout, copy, clamp))
+
+ val warnings = fixture.doHighlighting(HighlightSeverity.WARNING)
+ .filter { it.description?.contains("fully qualified") == true }
+ .map { fixture.editor.document.getText(TextRange(it.startOffset, it.endOffset)) }
+ assertEquals(listOf("Mth", "String"), warnings)
+
+ fixture.editor.caretModel.moveToOffset(fixture.editor.document.text.indexOf("[Mth]") + 2)
+ // launchAction can't be used, since it tries to make the light file read only on disk
+ val fix = fixture.findSingleIntention("Replace with 'net.minecraft.util.Mth'")
+ WriteCommandAction.runWriteCommandAction(project) { fix.invoke(project, fixture.editor, fixture.file) }
+ assertTrue(fixture.editor.document.text.contains("See [net.minecraft.util.Mth], "))
+ }
+
+ @Test
+ @DisplayName("Class name completion inserts fully qualified names")
+ fun completion() {
+ val copy = clampCopy("See [Lev].")
+ fixture.openFileInEditor(AnnotaterJavadocVirtualFile(checkout, copy, clamp))
+ fixture.editor.caretModel.moveToOffset(fixture.editor.document.text.indexOf("Lev]") + 3)
+ val lookup = fixture.completeBasic()
+ // java.util.logging.Level is also offered
+ val level = lookup.single { (it.psiElement as? PsiClass)?.qualifiedName == "net.minecraft.world.level.Level" }
+ fixture.lookup.currentItem = level
+ fixture.finishLookup(Lookup.NORMAL_SELECT_CHAR)
+ assertTrue(
+ fixture.editor.document.text.contains("/// See [net.minecraft.world.level.Level]."),
+ fixture.editor.document.text,
+ )
+ }
+
+ @Test
+ @DisplayName("Quick documentation shows the annotater Javadoc instead of the existing one")
+ fun quickDocumentation() {
+ val clampMethod = findMember("net.minecraft.util.Mth", "clamp")
+ val doc = AnnotaterJavadocRendering.generateDoc(clampMethod, "Clamps a **value**.")!!
+ assertTrue("Clamps a value." in doc || "Clamps a value." in doc, doc)
+ assertFalse("Existing method docs" in doc, doc)
+
+ val hidden = AnnotaterJavadocRendering.generateDoc(clampMethod, null)!!
+ assertFalse("Existing method docs" in hidden, hidden)
+ }
+}
diff --git a/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt
new file mode 100644
index 000000000..0328db7d0
--- /dev/null
+++ b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt
@@ -0,0 +1,141 @@
+/*
+ * Minecraft Development for IntelliJ
+ *
+ * https://mcdev.io/
+ *
+ * Copyright (C) 2026 minecraft-dev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published
+ * by the Free Software Foundation, version 3.0 only.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+package com.demonwav.mcdev.platform.fabric.annotater
+
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocStore
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarget.ClassTarget
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarget.FieldTarget
+import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarget.MethodTarget
+import java.nio.file.Path
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
+import kotlin.io.path.readText
+import kotlin.io.path.writeText
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+@DisplayName("Annotater Javadoc Store Tests")
+class AnnotaterJavadocStoreTest {
+ @TempDir
+ lateinit var mappings: Path
+
+ private val mth = "net/minecraft/util/Mth"
+
+ // The only file on annotater's 26.3 branch at the time of writing
+ private val mthMapping = """
+ |CLASS net/minecraft/util/Mth
+ | COMMENT Math utilities.
+ | METHOD clamp (III)I
+ | COMMENT Clamps `value` between `min` and `max` (inclusive).
+ | COMMENT
+ | COMMENT @param value the value to clamp
+ | COMMENT @param min the minimum value
+ | COMMENT @param max the maximum value
+ | COMMENT @return the clamped value
+ |
+ """.trimMargin()
+
+ private fun writeMth(): Path {
+ val file = mappings.resolve("$mth.mapping")
+ file.parent.createDirectories()
+ file.writeText(mthMapping)
+ return file
+ }
+
+ @Test
+ @DisplayName("Reads class and method comments")
+ fun read() {
+ writeMth()
+ val store = AnnotaterJavadocStore(mappings)
+ assertEquals("Math utilities.", store.get(ClassTarget(mth)))
+ assertEquals(
+ """
+ Clamps `value` between `min` and `max` (inclusive).
+
+ @param value the value to clamp
+ @param min the minimum value
+ @param max the maximum value
+ @return the clamped value
+ """.trimIndent(),
+ store.get(MethodTarget(mth, "clamp", "(III)I")),
+ )
+ assertNull(store.get(MethodTarget(mth, "clamp", "(FFF)F")))
+ }
+
+ @Test
+ @DisplayName("Rewriting unchanged comments produces an identical file")
+ fun roundTrip() {
+ val file = writeMth()
+ val store = AnnotaterJavadocStore(mappings)
+ store.set(ClassTarget(mth), store.get(ClassTarget(mth)))
+ assertEquals(mthMapping, file.readText())
+ }
+
+ @Test
+ @DisplayName("Adding a comment keeps the rest of the file")
+ fun addComment() {
+ val file = writeMth()
+ val store = AnnotaterJavadocStore(mappings)
+ store.set(FieldTarget(mth, "PI", "F"), "Pi as a float.")
+ assertEquals(
+ mthMapping.replace("\tMETHOD clamp", "\tFIELD PI F\n\t\tCOMMENT Pi as a float.\n\tMETHOD clamp"),
+ file.readText(),
+ )
+ assertEquals("Pi as a float.", AnnotaterJavadocStore(mappings).get(FieldTarget(mth, "PI", "F")))
+ }
+
+ @Test
+ @DisplayName("Removing the last comment deletes the file")
+ fun removeComments() {
+ val file = writeMth()
+ val store = AnnotaterJavadocStore(mappings)
+ store.set(MethodTarget(mth, "clamp", "(III)I"), null)
+ assertEquals("CLASS net/minecraft/util/Mth\n\tCOMMENT Math utilities.\n", file.readText())
+ store.set(ClassTarget(mth), "")
+ assertFalse(file.exists())
+ }
+
+ @Test
+ @DisplayName("Inner classes are stored in their outer class's file")
+ fun innerClass() {
+ val store = AnnotaterJavadocStore(mappings)
+ val outer = "net/minecraft/world/level/Level"
+ store.set(ClassTarget("$outer\$Inner"), "An inner class.")
+ store.set(ClassTarget(outer), "The level.")
+ // Enigma nests inner classes inside their outer class
+ assertEquals(
+ "CLASS $outer\n\tCOMMENT The level.\n\tCLASS Inner\n\t\tCOMMENT An inner class.\n",
+ mappings.resolve("$outer.mapping").readText(),
+ )
+ assertEquals("An inner class.", AnnotaterJavadocStore(mappings).get(ClassTarget("$outer\$Inner")))
+ }
+
+ @Test
+ @DisplayName("Outer class names")
+ fun outerClassName() {
+ assertEquals("a/B", ClassTarget("a/B\$C\$D").outerClass)
+ assertEquals("a/\$B", ClassTarget("a/\$B").outerClass)
+ }
+}