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