User: joe Date: 10 Nov 26 23:11 Revision: 8b7df1d2f39176871145907ee04ba4c74af56cf8 Summary: Start on annotater editor TeamCity URL: http://ci.mcdev.io:80/viewModification.html?tab=vcsModificationFiles&modId=10706&personal=false The following binary files were skipped: src/main/resources/assets/icons/general/Annotater.png src/main/resources/assets/icons/general/Annotater@2x.png src/main/resources/assets/icons/general/AnnotaterLarge.png src/main/resources/assets/icons/general/AnnotaterLarge@2x.png Index: annotater-review.md =================================================================== --- annotater-review.md (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ annotater-review.md (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,28 @@ +- Add a way to delete a contribution (with an "are you sure" dialog). Only delete it locally, don't touch the remotes or any pull request. +- Add a checkbox in the pull request dialog (on by default) to check the build before creating the pull request. This is done by running `./gradlew check` in the annotater directory. This can take a minute or so to run, so a progress dialog is required. If the check fails, the user should be presented with a dialog containing the failure log, with options to cancel (default) or submit anyway. +- If a pull request has already been created for a contribution, the "create pull request" button should be replaced with an "update pull request" button, which should only appear if there are modifications to be pushed (just as "create pull request" should). +- When you open a pull request, why do you browse to it and then add a button to browse to it? I would just leave it at the button. +- `AnnotaterJavadocCommentInspection.findMarkdownMigrationFixes` should instead use reflection to construct the fix. Put the reference to the constructor in `McdevReflection`. +- The method names in `AnnotaterJavadocContributions`: `switchTo`, `checkUncommittedChanges`, and `switch` aren't clear enough, especially `checkUncommittedChanges` doesn't really describe that it does the switch afterwards. +- The `DocumentationProvider` docs recommend migrating to `DocumentationTarget`. Evaluate whether we can do that for `AnnotaterJavadocDocumentationProvider`, and if we can, do it. +- `AnnotaterJavadocEditing.edit` can be renamed to an overload of `open` (can therefore move the methods into a more logical order). +- `AnnotaterJavadocEditing.openEditor` uses `ReadAction.compute`, which is deprecated. +- Rename the icon to `tiny_potato`. +- What is `AnnotaterJavadocInlayPassFactory.Pass.active` for? If it's a workaround for something, a comment would be nice. +- Companion objects inside extension point impl classes sometimes generate an intellij warning. Where these appear, change them to named objects, as we do in the rest of the project. +- Storing the inlay for `offset` in `AnnotaterJavadocInlayRenderer` looks like a bit of a hack. Is there precedent for this in IntelliJ's codebase? Why can't `initialOffset` always be used? +- `AnnotaterJavadocInlayRenderer` attempts to get proper Javadoc highlighting colors with the token thing, and while it does properly turn the text green, it doesn't highlight other things, like turning the color of referenced parameters. Fix this if it's an easy fix, otherwise tell me how you would fix it and wait for my approval before fixing it. +- `AnnotaterJavadocInlineEditor` uses `Alarm` which is obsolete. Follow the advice in the `Alarm` docs to replace it, if possible. If that's kinda complicated, then not saving until editing is finished is acceptable. +- Since it now should be difficult to accidentally edit readonly sections, the `ReadonlyFragmentModificationHandler` can be removed. +- The document modification guards should be not greedy at the end close to the edited javadoc, and greedy on the other end. +- In `AnnotaterJavadocInlineEditor.dispose`, it disposes the inlay directly. Isn't the normal solution to this to register the inlay with `Disposer.register` instead? +- Why does `AnnotaterJavadocInlineEditor.ProjectDisposable` exist, why not register the inline editor disposable directly with the project (which is itself disposable)? +- In `AnnotaterJavadocRefresher`, use `BulkFileListenerBackgroundable` instead. +- Why is `AnnotaterJavadocRendering.getJavadoc` in that class? I feel like that would fit better in another class. +- Make it so `AnnotaterJavadocSourceCopy.createVirtualFile` uses the language level of the file it's copied from, or Java 23 (the version Markdown Javadocs were introduced), whichever is newer. Same in `AnnotaterJavadocVirtualFile`. +- Why do we have both `AnnotaterJavadocSourceCopy.createVirtualFile` and `AnnotaterJavadocVirtualFile`? +- Local classes should be possible to document. Members within local and anonymous classes should also be possible to document. +- There's a lot of methods throughout this feature that are public but could be made private. +- The `EnigmaFileWriter` `VisitOrder` doesn't match what is used by annotater. Also you should update `mapping-io` in mcdev to the latest version. +- Fix the lint error on `EditAnnotaterJavadocIntention`. +- Find a built-in or library equivalent to `AnnotaterConfig.validatePath`, rather than writing it yourself. \ No newline at end of file Index: build.gradle.kts =================================================================== --- build.gradle.kts (revision 22e0113cc47e4ed1a03eea7a14a7abb701533e39) +++ build.gradle.kts (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -127,6 +127,7 @@ bundledPlugin("org.jetbrains.kotlin") bundledPlugin("org.toml.lang") bundledPlugin("org.jetbrains.plugins.yaml") + bundledPlugin("org.jetbrains.plugins.github") testFramework(TestFrameworkType.JUnit5) testFramework(TestFrameworkType.Platform) Index: buildSrc/src/main/kotlin/mcdev-core.gradle.kts =================================================================== --- buildSrc/src/main/kotlin/mcdev-core.gradle.kts (revision 22e0113cc47e4ed1a03eea7a14a7abb701533e39) +++ buildSrc/src/main/kotlin/mcdev-core.gradle.kts (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -193,6 +193,7 @@ tasks.runIde { maxHeapSize = "2G" jvmArgs("--add-exports=java.base/jdk.internal.vm=ALL-UNNAMED") + systemProperty("mcdev.annotater.upstream", "Earthcomputer/annotater") } tasks.test { Index: src/main/kotlin/asset/GeneralAssets.kt =================================================================== --- src/main/kotlin/asset/GeneralAssets.kt (revision 22e0113cc47e4ed1a03eea7a14a7abb701533e39) +++ src/main/kotlin/asset/GeneralAssets.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -25,4 +25,7 @@ val PLUGIN = loadIcon("/assets/icons/general/plugin.png") val MC_TEMPLATE = loadIcon("/assets/icons/general/MinecraftTemplate.png") + + val ANNOTATER = loadIcon("/assets/icons/general/Annotater.png") + val ANNOTATER_LARGE = loadIcon("/assets/icons/general/AnnotaterLarge.png") } Index: src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,38 @@ +/* + * 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 + +/** + * An annotater checkout on disk for one Minecraft version. + * + * @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(mappingsDir) +} Index: src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,215 @@ +/* + * 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.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 com.intellij.psi.util.PsiUtilCore +import java.io.IOException +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.Path +import kotlin.io.path.exists + +/** + * Finds the annotater checkout to use for Minecraft classes. + */ +@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() + } + }, + ) + } + + 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() + } + + /** + * Finds the checkout for [version] according to the user's settings, ignoring whether the feature is enabled. + */ + 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 = PsiUtilCore.getVirtualFile(element) ?: return Status.UnknownMinecraftVersion + val version = minecraftVersion(element.project, file) ?: return Status.UnknownMinecraftVersion + return statusForVersion(version) + } + + /** + * The checkout to use for [element], or null if annotater is disabled, [element] isn't part of Minecraft, or no + * checkout exists for its Minecraft version. + */ + fun checkoutFor(element: PsiElement): AnnotaterCheckout? { + if (!AnnotaterSettings.instance.enabled) { + return null + } + val file = PsiUtilCore.getVirtualFile(element) ?: return null + return checkoutFor(element.project, file) + } + + /** + * The checkout to use for [file] in [project], or null if annotater is disabled, [file] isn't part of Minecraft, + * or no checkout exists for its Minecraft version. + */ + 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) + + /** + * Whether any module of [project] is a Fabric mod. Annotater is a Fabric project, so it's offered more + * prominently there. + */ + fun isFabricProject(project: Project): Boolean { + return ModuleManager.getInstance(project).modules.any { MinecraftFacet.getInstance(it, FabricModuleType) != null } + } + + /** + * Whether [file] is a class or source file from a Minecraft jar, as opposed to project sources or another + * library. + */ + 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() } + } + } +} Index: src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,83 @@ +/* + * 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 java.io.IOException +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +/** + * The contents of an annotater checkout's `config.json`. + */ +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(raw.mappings ?: throw IOException("$FILE_NAME is missing \"mappings\"")) + val unpick = raw.unpickDefinitions?.let(::validatePath) + return AnnotaterConfig(version, mappings, unpick) + } + + /** + * Paths must be relative subdirectories using forward slashes, like mcsrc requires. + */ + @Throws(IOException::class) + private fun validatePath(path: String): String { + val segments = path.split('/') + val valid = path.isNotEmpty() && + !path.startsWith('/') && + '\\' !in path && + ':' !in path && + segments.none { it.isEmpty() || it == "." || it == ".." } + if (!valid) { + throw IOException("Invalid path in $FILE_NAME: \"$path\"") + } + return path + } + } +} Index: src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,228 @@ +/* + * 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.ANNOTATER_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 { + checkBox(MCDevBundle("fabric.annotater.settings.show_javadocs")) + .bindSelected(settings::showAnnotaterJavadocs) + .comment(MCDevBundle("fabric.annotater.settings.show_javadocs.comment")) + } + 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 } + + /** + * Checks that [directory] is an annotater checkout for a Minecraft version used by an open project, returning + * 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 + } + + 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() + + companion object { + /** + * The Minecraft versions of all modules in open projects, as configured in their Minecraft module settings. + */ + fun knownMinecraftVersions(): List { + return ProjectManager.getInstance().openProjects.flatMap { project -> + ModuleManager.getInstance(project).modules.mapNotNull { + MinecraftModuleSettings.getInstance(it).state.minecraftVersion + } + }.filter { it.isNotBlank() }.distinct() + } + + fun show(project: Project?) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, AnnotaterConfigurable::class.java) + } + } +} Index: src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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) + } +} Index: src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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" + + 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.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) + } +} Index: src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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) + } +} Index: src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,119 @@ +/* + * 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 + +/** + * Git operations on managed annotater checkouts. 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? + + /** + * The contribution branches in the managed [checkout], newest first. Must not be called on the EDT. + */ + fun contributions(project: Project?, checkout: AnnotaterCheckout): List + + /** + * The mapping files in [checkout] that have changed since the last commit. 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, + ) + + companion object { + val instance: AnnotaterVcs? + get() = ApplicationManager.getApplication().getService(AnnotaterVcs::class.java) + } +} + +/** + * A local branch in a managed checkout holding one contribution, which becomes one pull request. + * + * @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 { + /** + * A pull request title or commit message describing changes to the mapping [files]. + */ + 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" + } + } + } +} + +/** + * Creating pull requests to annotater. Only available when the GitHub plugin is installed. + */ +interface AnnotaterPullRequests { + fun canCreatePullRequest(checkout: AnnotaterCheckout): Boolean = checkout.managed + + fun createPullRequest(project: Project, checkout: AnnotaterCheckout) + + companion object { + val instance: AnnotaterPullRequests? + get() = ApplicationManager.getApplication().getService(AnnotaterPullRequests::class.java) + } +} Index: src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,426 @@ +/* + * 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 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() + + 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) + 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 checked out branch straight from the worktree's `HEAD` file, 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 + } + + /** + * Updates the title of the checked out contribution shown in editor banners. + */ + 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() + } + } + + /** + * The title of the pull request already opened for the contribution [branch], if any. + */ + 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 + } + } + } + + /** + * Remembers the title of the pull request for the contribution [branch], as its 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() + } + + 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) + } + } + + /** + * The number of commits on the current branch that aren't in the upstream version branch. + */ + 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() + } + + 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/" + const val UPSTREAM_REMOTE = "origin" + + /** + * 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") + val upstreamUrl = "https://github.com/$upstreamRepository.git" + + val instance: AnnotaterGitVcs? + get() = AnnotaterVcs.instance as? AnnotaterGitVcs + } +} Index: src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,312 @@ +/* + * 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 + +/** + * Commits annotater changes, forks annotater on GitHub, pushes the contribution branch to the fork and opens a pull + * request, using accounts from the GitHub plugin. + */ +class AnnotaterGitHubPullRequests : AnnotaterPullRequests { + override fun canCreatePullRequest(checkout: AnnotaterCheckout) = + checkout.managed && AnnotaterGitVcs.instance != null + + override fun createPullRequest(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 + + 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) } + } + + 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) + } + + 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?, + ) { + 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) + if (!dialog.showAndGet()) { + return + } + val account = dialog.account ?: return + val token = GHCompatibilityUtil.getOrRequestToken(account, project) ?: return + + val request = PullRequest(dialog.prTitle, dialog.prDescription, dialog.commitMessage, branch) + object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.pr.creating"), false) { + private var url: String? = null + + override fun run(indicator: ProgressIndicator) { + url = + submit(project, checkout, git, account, token, request, uncommittedChanges.isNotEmpty(), indicator) + // 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 + Notification( + AnnotaterNotifications.GROUP_ID, + MCDevBundle("fabric.annotater.pr.created"), + url, + NotificationType.INFORMATION, + ).addAction( + NotificationAction.createSimple(MCDevBundle("fabric.annotater.pr.open")) { + BrowserUtil.browse(url) + }, + ).addAction( + NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.pr.start_new")) { + AnnotaterJavadocContributions.switchTo(project, checkout, null) + }, + ).notify(project) + BrowserUtil.browse(url) + } + + override fun onThrowable(error: Throwable) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.pr.failed"), error) + } + }.queue() + } + + private class PullRequest(val title: String, val body: String, val commitMessage: String, val branch: String) + + @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 (!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) + } + + 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) + + 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 + } +} Index: src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,88 @@ +/* + * 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.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?, +) : DialogWrapper(project) { + private val accountModel = CollectionComboBoxModel(accounts, defaultAccount ?: accounts.first()) + + var prTitle = existingTitle ?: AnnotaterContribution.defaultTitle(changes) + var prDescription = "" + var commitMessage = AnnotaterContribution.defaultTitle(uncommittedChanges) + + val account: GithubAccount? + get() = accountModel.selected + + init { + 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) + } + 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")) { + textField().bindText(::commitMessage).align(AlignX.FILL) + } + } + if (changes.isNotEmpty()) { + row { + comment( + MCDevBundle( + "fabric.annotater.pr.dialog.changes", + changes.joinToString("
") { it.substringAfterLast('/') }, + ), + ) + } + } + }.apply { + preferredSize = Dimension(560, preferredSize.height) + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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 +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,88 @@ +/* + * 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.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 +import com.siyeh.ig.migration.MarkdownDocumentationCommentsMigrationInspection + +/** + * 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, + *findMarkdownMigrationFixes(holder, comment), + ) + } + + 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, + ) + } + } + } + + /** + * Reuses the IDE's own "convert to Markdown documentation comment" quick fix. + */ + private fun findMarkdownMigrationFixes(holder: ProblemsHolder, comment: PsiDocComment): Array { + val migrationHolder = ProblemsHolder(holder.manager, holder.file, holder.isOnTheFly) + comment.accept(MarkdownDocumentationCommentsMigrationInspection().buildVisitor(migrationHolder, true)) + return migrationHolder.results + .flatMap { it.fixes.orEmpty().asIterable() } + .filterIsInstance() + .toTypedArray() + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,222 @@ +/* + * 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 + +/** + * Switching a managed annotater checkout between contributions, so that a pull request can be updated after review. + */ +object AnnotaterJavadocContributions { + /** + * The label for the contribution [checkout] is on, for the editor banner. + */ + fun currentLabel(vcs: AnnotaterVcs, checkout: AnnotaterCheckout): String = + vcs.currentContribution(checkout)?.displayName ?: MCDevBundle("fabric.annotater.contribution.new") + + /** + * Shows a popup under [component] for switching between contributions if [checkout] is managed, and configuring + * annotater. + */ + 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) { + switchTo(project, checkout, null) + }, + ) + group.add(Separator.getInstance()) + } + group.add( + DumbAwareAction.create(MCDevBundle("fabric.annotater.configure"), AllIcons.General.Settings) { + AnnotaterConfigurable.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) { + switchTo(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 switchTo(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 + } + checkUncommittedChanges(project, checkout, vcs, branch) + } + } + } + + private fun checkUncommittedChanges( + 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()) { + switch(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 + switch(project, checkout, vcs, branch, message) + } + + override fun onThrowable(error: Throwable) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.contribution.failed"), error) + } + }.queue() + } + + private fun switch( + 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 javadocEditor(project: Project, checkout: AnnotaterCheckout): AnnotaterJavadocInlineEditor? = + AnnotaterJavadocInlineEditor.current(project)?.takeIf { it.checkout.root == checkout.root } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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.javadoc + +import com.intellij.lang.documentation.AbstractDocumentationProvider +import com.intellij.openapi.project.DumbService +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMember + +/** + * 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, AnnotaterJavadocRendering.getJavadoc(checkout, target)) + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,197 @@ +/* + * 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.PsiAnonymousClass +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiField +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMember +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiTypeParameter +import com.intellij.psi.SmartPointerManager +import com.intellij.psi.impl.compiled.ClsElementImpl +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.PsiUtilCore +import java.io.IOException + +object AnnotaterJavadocEditing { + /** + * 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) } + } + + /** + * Edits the annotater Javadoc of the member found by [findTarget]. + */ + fun edit(project: Project, editor: Editor, file: PsiFile) { + open(project, findTarget(editor, file) ?: return) + } + + /** + * Converts an element of a decompiled class's text to the compiled element it was generated from, and filters out + * elements that can't have annotater Javadocs. + */ + fun normalize(element: PsiElement): PsiMember? { + val compiled = element.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: element + return when (compiled) { + is PsiTypeParameter, is PsiAnonymousClass -> null + is PsiClass -> compiled.takeIf { it.qualifiedName != null } + is PsiField, is PsiMethod -> compiled + else -> null + } + } + + /** + * Whether to offer editing annotater Javadocs in [project]. 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) + } + + fun isDocumentable(member: PsiMember): Boolean { + val file = PsiUtilCore.getVirtualFile(member) ?: return false + return AnnotaterCheckoutService.isMinecraftFile(member.project, file) && + AnnotaterJavadocTarget.fromPsi(member) != null + } + + /** + * Starts editing the annotater Javadoc of [member], in an editor embedded in the Minecraft class it's 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.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.compute { + 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 = AnnotaterJavadocRendering.displayedMembers(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) + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,93 @@ +/* + * 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.AnnotaterPullRequests +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService +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 + +/** + * Shows a banner in Minecraft classes that annotater Javadocs are displayed in, i.e. that have an annotater checkout. + */ +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 + + 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 pullRequests = AnnotaterPullRequests.instance + if (pullRequests != null && pullRequests.canCreatePullRequest(checkout)) { + panel.createActionLabel(MCDevBundle("fabric.annotater.create_pull_request")) { + val saved = javadocEditor?.save() ?: CompletableFuture.completedFuture(true) + saved.thenAccept { success -> + if (success) { + ApplicationManager.getApplication().invokeLater { + pullRequests.createPullRequest(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 + } + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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 +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,211 @@ +/* + * 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 + +/** + * Displays annotater Javadocs in Minecraft classes, both decompiled and attached sources. Existing doc comments are + * folded away, and annotater Javadocs are shown as block inlays above their declarations. Declarations with an + * annotater Javadoc also get a gutter icon to edit it. + * + * 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 active = false + 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 + active = true + 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 AnnotaterJavadocRendering.displayedMembers(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 = AnnotaterJavadocRendering.getJavadoc(checkout, 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(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(INLAYS_KEY, inlays.takeIf { it.isNotEmpty() }) + + editor.getUserData(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(GUTTER_ICONS_KEY, highlighters.takeIf { it.isNotEmpty() }) + + val oldFolds = editor.getUserData(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(FOLDS_KEY, newFolds.takeIf { it.isNotEmpty() }) + } + + val hideRenderedDocs = active && showJavadocs + if (hideRenderedDocs && DocRenderManager.isDocRenderingEnabled(editor)) { + DocRenderManager.setDocRenderingEnabled(editor, false) + editor.putUserData(DOC_RENDERING_DISABLED_KEY, true) + } else if (!hideRenderedDocs && editor.getUserData(DOC_RENDERING_DISABLED_KEY) == true) { + DocRenderManager.resetEditorToDefaultState(editor) + editor.putUserData(DOC_RENDERING_DISABLED_KEY, null) + } + } + } + + private class EditGutterIconRenderer(private val pointer: SmartPsiElementPointer) : + GutterIconRenderer() { + override fun getIcon(): Icon = GeneralAssets.ANNOTATER + + 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) + } + } + + companion object { + private val INLAYS_KEY = Key.create>>("mcdev.annotater.javadocInlays") + private val FOLDS_KEY = Key.create>("mcdev.annotater.javadocFolds") + private val GUTTER_ICONS_KEY = Key.create>("mcdev.annotater.javadocGutterIcons") + private val DOC_RENDERING_DISABLED_KEY = Key.create("mcdev.annotater.docRenderingDisabled") + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,136 @@ +/* + * 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 { + private var inlay: Inlay<*>? = null + + // The inlay constructor calculates the size before the inlay is added to the tree, while its offset is still + // meaningless + private val offset: Int + get() = (inlay?.takeIf { it.isValid }?.offset ?: initialOffset).coerceIn(0, editor.document.textLength) + + override fun calcWidthInPixels(inlay: Inlay<*>): Int { + this.inlay = inlay + val indent = maxOf(0, startX() - editor.insets.left) + return indent + lines().maxOf { line -> line.sumOf { it.width() } } + } + + override fun calcHeightInPixels(inlay: Inlay<*>): Int { + this.inlay = inlay + return lines().size * editor.lineHeight + } + + override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) { + this.inlay = inlay + val startX = startX() + 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)) + + /** + * The x coordinate of the declaration, where the comment starts. + */ + private fun startX(): Int { + 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 + + /** + * The doc comment, split into lines of tokens highlighted as they would be in a Java file. + */ + 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 + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,529 @@ +/* + * 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.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.Inlay +import com.intellij.openapi.editor.RangeMarker +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.editor.actionSystem.EditorActionManager +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.LocalFileSystem +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.Alarm +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 + +/** + * 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, + /** + * The edited member in the Minecraft class it's displayed in, so that editing can be restarted when the checkout + * switches branch. + */ + val source: SmartPsiElementPointer?, +) : Disposable { + private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) + + private val guards = mutableListOf() + + @Volatile + private var dirty = false + + /** + * The editor of the Minecraft class that the Javadoc is edited in. + */ + @Volatile + var hostEditor: Editor? = null + private set + + /** + * The editor embedded in [hostEditor] that edits the Javadoc. + */ + var editor: EditorEx? = null + private set + + private var inlay: Inlay<*>? = null + + val checkout: AnnotaterCheckout + get() = file.checkout + + val target: AnnotaterJavadocTarget + get() = file.target + + init { + installGuardedBlocks() + // The default handler shows a modal dialog, which is annoying when editing a guarded block by accident + EditorActionManager.getInstance().setReadonlyFragmentModificationHandler(document) { + val editor = editor ?: return@setReadonlyFragmentModificationHandler + HintManager.getInstance().showErrorHint(editor, MCDevBundle("fabric.annotater.javadoc.read_only")) + } + document.addDocumentListener( + object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + dirty = true + alarm.cancelAllRequests() + alarm.addRequest(::save, SAVE_DELAY_MS) + } + }, + 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. + */ + private fun installGuardedBlocks() { + val range = commentRange() ?: return + addGuard(0, range.startOffset) + addGuard(range.endOffset, document.textLength) + } + + private fun addGuard(start: Int, end: Int) { + if (end <= start) { + return + } + val guard = document.createGuardedBlock(start, end) + guard.isGreedyToLeft = false + guard.isGreedyToRight = false + guards += guard + } + + /** + * Shows the embedded editor above [offset] in [host], and focuses it. + */ + 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 } + } + }, + ) + + inlay = EditorEmbeddedComponentManager.getInstance().addComponent( + host, + component, + EditorEmbeddedComponentManager.Properties( + EditorEmbeddedComponentManager.ResizePolicy.none(), + null, + false, + true, + false, + true, + 0, + offset, + ), + ) + + // 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() + alarm.cancelAllRequests() + 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)) + LocalFileSystem.getInstance().refreshNioFiles(listOf(store.fileFor(outerClass))) + 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) + } + inlay?.let(Disposer::dispose) + inlay = null + editor?.let(EditorFactory.getInstance()::releaseEditor) + editor = null + for (guard in guards) { + document.removeGuardedBlock(guard) + } + guards.clear() + EditorActionManager.getInstance().setReadonlyFragmentModificationHandler(document, null) + if (wasCurrent && !project.isDisposed) { + restartHostHighlighting() + EditorNotifications.getInstance(project).updateAllNotifications() + } + hostEditor = null + } + + companion object { + private const val SAVE_DELAY_MS = 750 + + private val CURRENT_KEY = Key.create("mcdev.annotater.javadocInlineEditor") + + private val saveExecutor = + AppExecutorUtil.createBoundedApplicationPoolExecutor("Annotater Javadoc saving", 1) + + /** + * The annotater Javadoc being edited in [project], if any. + */ + fun current(project: Project): AnnotaterJavadocInlineEditor? = project.getUserData(CURRENT_KEY) + + /** + * The parent of the inline editors of a project, so they're disposed with it. + */ + @Service(Service.Level.PROJECT) + private class ProjectDisposable : Disposable { + override fun dispose() { + } + } + + /** + * Whether [range] of [document] is entirely within one guarded block, so 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 + } + + /** + * Starts editing the Javadoc of [file]'s target, finishing the previous editing in the project + * first. Returns null if the previous editing 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 + } + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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) + } + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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.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.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.ui.EditorNotifications +import java.nio.file.Path + +/** + * Refreshes the places annotater Javadocs are displayed when they change, whether through the plugin or on disk. + */ +class AnnotaterJavadocRefresher : AnnotaterListener, BulkFileListener { + 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() + } + } + } + } + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,97 @@ +/* + * 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.openapi.diagnostic.thisLogger +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiCompiledElement +import com.intellij.psi.PsiJavaDocumentedElement +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMember +import com.intellij.psi.util.PsiTreeUtil +import java.io.IOException + +/** + * 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 { + /** + * Finds the checkout whose Javadocs should be displayed instead of the existing Javadoc of [member], or null if + * the existing Javadoc should be shown. + */ + fun checkoutForDisplay(member: PsiMember): AnnotaterCheckout? { + if (!AnnotaterSettings.instance.showAnnotaterJavadocs) { + return null + } + return AnnotaterCheckoutService.instance.checkoutFor(member) + } + + /** + * All classes, fields and methods declared in [file] and its inner classes. + */ + fun displayedMembers(file: PsiJavaFile): Sequence = members(file.classes) + + private fun members(classes: Array): Sequence = classes.asSequence().flatMap { clazz -> + sequenceOf(clazz) + clazz.fields + clazz.methods + members(clazz.innerClasses) + } + + /** + * 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 + } + + fun getJavadoc(checkout: AnnotaterCheckout, target: AnnotaterJavadocTarget): String? { + return try { + if (checkout.javadocStore.hasFile(target.outerClass)) checkout.javadocStore.get(target) else null + } catch (e: IOException) { + thisLogger().info("Failed to read annotater Javadoc of $target", e) + null + } + } + + /** + * Finds the declaration of [member] in a copy of its source file with [javadoc] as its doc comment. + */ + 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 + } + + /** + * Generates the Quick Documentation of [member] using [javadoc] as its description. + */ + fun generateDoc(member: PsiMember, javadoc: String?): String? { + val declaration = copiedDeclaration(member, javadoc) ?: return null + return JavaDocumentationProvider.generateDocStatic(declaration, null) + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,233 @@ +/* + * 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.impl.compiled.ClsElementImpl +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 [members] 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, +) { + /** + * 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 + } + + fun createVirtualFile(): LightVirtualFile = LightVirtualFile(fileName, JavaFileType.INSTANCE, text).also { + // Markdown doc comments are only parsed at a high enough language level + it.putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, LanguageLevel.HIGHEST) + } + + /** + * 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 members(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 = members(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 = members(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 { + /** + * Finds the classes, fields and methods declared in [file], in the order matching [targets]. Local and + * anonymous classes are not included, and neither are members that don't appear in the source, such as the + * implicit methods of records. + */ + fun members(file: PsiJavaFile): List = members(file.classes).toList() + + private fun members(classes: Array): Sequence = classes.asSequence().flatMap { clazz -> + sequenceOf(clazz) + clazz.fields + clazz.methods + members(clazz.innerClasses) + }.filter { it.node != null } + + /** + * Extracts the Markdown text of a `///` doc comment, removing the `///` prefixes and the common indentation. + * Returns null when the comment is blank. + */ + 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 } + } + + /** + * Formats Markdown as a `///` doc comment with the given indentation, ending with a newline. + */ + 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. + */ + 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 = generateSequence(element as? PsiClass ?: element.containingClass) { it.containingClass } + .lastOrNull() ?: 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 members(source)) { + // Decompiled text is generated from the compiled class, which is more reliable to get the target of + val compiled = member.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: member + val target = AnnotaterJavadocTarget.fromPsi(compiled)?.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) + } + return AnnotaterJavadocSourceCopy("${outermost.name}.java", sb.toString(), targets) + } + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,250 @@ +/* + * 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 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.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 + +/** + * Reads and writes the Javadoc comments stored in an annotater `mappings` directory. Comments are stored in Enigma + * format, with one file per top level class at `/.mapping`. The reading and writing + * matches mcsrc.dev so that files edited by either tool produce the same diffs. + */ +class AnnotaterJavadocStore(val mappingsDir: Path) { + private val cache = ConcurrentHashMap() + + fun fileFor(outerClass: String): Path = mappingsDir.resolve("$outerClass.mapping") + + fun hasFile(outerClass: String): Boolean = fileFor(outerClass).exists() + + @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 + } + } + + /** + * 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)) + } + + /** + * Like [set], but for several targets at once. 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) + } + + fun invalidateAll() { + cache.clear() + } + + @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 { + @Throws(IOException::class) + 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) + 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() + EnigmaFileWriter(output).use { writer -> + meaningful.accept(writer, VisitOrder.createByName().classesBySrcNameShortFirst()) + } + 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 } + } + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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.javadoc + +import com.demonwav.mcdev.util.descriptor +import com.demonwav.mcdev.util.internalName +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiField +import com.intellij.psi.PsiMethod + +/** + * A class, field or method that can have a Javadoc comment in annotater. All names are internal (slash separated) + * official names, and members are identified by their name and JVM descriptor. + */ +sealed class AnnotaterJavadocTarget { + abstract val owner: String + + /** + * The internal name of 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 { + fun fromPsi(element: PsiElement): AnnotaterJavadocTarget? { + return when (element) { + is PsiClass -> { + if (element.qualifiedName == null) { + // Anonymous and local classes can't be documented + return null + } + ClassTarget(element.internalName ?: return null) + } + + is PsiField -> { + val owner = element.containingClass?.takeIf { it.qualifiedName != null } ?: return null + FieldTarget(owner.internalName ?: return null, element.name, element.descriptor ?: return null) + } + + is PsiMethod -> { + val owner = element.containingClass?.takeIf { it.qualifiedName != null } ?: return null + MethodTarget( + owner.internalName ?: return null, + element.internalName, + element.descriptor ?: return null + ) + } + + else -> null + } + } + + /** + * 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 + } + } + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,93 @@ +/* + * 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 = 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) + } + } + + companion object { + /** + * Returns the fully qualified name of the class [reference] refers to, if it isn't already written as such. + */ + 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 } + } + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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 +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,57 @@ +/* + * 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.openapi.vfs.VirtualFile +import com.intellij.pom.java.LanguageLevel +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) { + /** + * The reason the last save failed, shown in the editor banner. + */ + @Volatile + var error: String? = null + + val outerClass: String + get() = copy.outerClass + + init { + putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, LanguageLevel.HIGHEST) + } + + companion object { + fun of(file: PsiFile?): AnnotaterJavadocVirtualFile? = + file?.viewProvider?.virtualFile as? AnnotaterJavadocVirtualFile + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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.ANNOTATER + 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.edit(project, editor, file) + } +} Index: src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt =================================================================== --- src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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.edit(project, editor, file) + } + + override fun generatePreview(project: Project, editor: Editor, file: PsiFile): IntentionPreviewInfo = + IntentionPreviewInfo.EMPTY +} Index: src/main/resources/META-INF/mcdev-annotater-git.xml =================================================================== --- src/main/resources/META-INF/mcdev-annotater-git.xml (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/resources/META-INF/mcdev-annotater-git.xml (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,28 @@ + + + + org.jetbrains.plugins.github + + + + + Index: src/main/resources/META-INF/mcdev-annotater-github.xml =================================================================== --- src/main/resources/META-INF/mcdev-annotater-github.xml (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/resources/META-INF/mcdev-annotater-github.xml (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,26 @@ + + + + + + + Index: src/main/resources/META-INF/plugin.xml =================================================================== --- src/main/resources/META-INF/plugin.xml (revision 22e0113cc47e4ed1a03eea7a14a7abb701533e39) +++ src/main/resources/META-INF/plugin.xml (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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"> + + + Index: src/main/resources/assets/icons/general/Annotater.LICENSE.txt =================================================================== --- src/main/resources/assets/icons/general/Annotater.LICENSE.txt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/resources/assets/icons/general/Annotater.LICENSE.txt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,3 @@ +Annotater.png, Annotater@2x.png, AnnotaterLarge.png and AnnotaterLarge@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/). Index: src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/description.html =================================================================== --- src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/description.html (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/description.html (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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. + + Index: src/main/resources/messages/MinecraftDevelopment.properties =================================================================== --- src/main/resources/messages/MinecraftDevelopment.properties (revision 22e0113cc47e4ed1a03eea7a14a7abb701533e39) +++ src/main/resources/messages/MinecraftDevelopment.properties (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -400,3 +400,90 @@ 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 Javadoc editing +fabric.annotater.settings.enabled.comment=Edit Minecraft Javadocs for annotater with the Edit Annotater Javadoc action on Minecraft classes, fields and methods, or the annotater gutter icon on declarations that already have an annotater Javadoc. +fabric.annotater.settings.show_javadocs=Show annotater Javadocs instead of existing Minecraft Javadocs +fabric.annotater.settings.show_javadocs.comment=Applies to quick documentation, decompiled classes and attached Minecraft sources. Rendered doc comments (reader mode) are turned off in Minecraft classes. +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=The plugin never runs git in this directory. Commit, push and open pull requests yourself. +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.dropdown={0} \u25BE +fabric.annotater.menu=Annotater +fabric.annotater.contribution.tooltip=Switch to another contribution, for example to update a pull request after review +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.status.disabled=Annotater is disabled in the Minecraft Development 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.javadoc.read_only=Only the Javadoc comment can be edited. +fabric.annotater.banner=Annotater Javadocs for Minecraft {0} from {1}. Use Edit Annotater Javadoc on a class, field or method to edit its Javadoc. 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.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.open=Open in Browser +fabric.annotater.pr.start_new=Start New Contribution +fabric.annotater.pr.dialog.ok=Create 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} Index: src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt =================================================================== --- src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -0,0 +1,403 @@ +/* + * 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.ide.DataManager +import com.intellij.ide.impl.HeadlessDataManager +import com.intellij.codeInsight.lookup.Lookup +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/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"), pi, clamp), copy.targets) + } + + @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, + ) + 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) + } +} Index: src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt =================================================================== --- src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) +++ src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt (revision 8b7df1d2f39176871145907ee04ba4c74af56cf8) @@ -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) + } +}