Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/mcdev-core.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/asset/GeneralAssets.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
36 changes: 36 additions & 0 deletions src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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)
}
204 changes: 204 additions & 0 deletions src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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<Path, AnnotaterCheckout>()

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<Path>): Pair<List<Pair<Path, String>>, Boolean> {
val changedJavadocs = mutableListOf<Pair<Path, String>>()
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<Boolean>("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() }
}
}
}
76 changes: 76 additions & 0 deletions src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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
}
}
}
Loading
Loading