|
| 1 | +import org.gradle.api.DefaultTask |
| 2 | +import org.gradle.api.file.DirectoryProperty |
| 3 | +import org.gradle.api.tasks.* |
| 4 | +import org.gradle.work.ChangeType |
| 5 | +import org.gradle.work.Incremental |
| 6 | +import org.gradle.work.InputChanges |
| 7 | +import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles |
| 8 | +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment |
| 9 | +import org.jetbrains.kotlin.com.intellij.openapi.Disposable |
| 10 | +import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer |
| 11 | +import org.jetbrains.kotlin.com.intellij.psi.PsiManager |
| 12 | +import org.jetbrains.kotlin.config.CompilerConfiguration |
| 13 | +import org.jetbrains.kotlin.lexer.KtTokens |
| 14 | +import org.jetbrains.kotlin.psi.KtClass |
| 15 | +import org.jetbrains.kotlin.psi.KtFile |
| 16 | +import org.jetbrains.kotlin.psi.KtNamedFunction |
| 17 | +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType |
| 18 | +import java.io.File |
| 19 | + |
| 20 | +/** |
| 21 | + * Gradle task which incrementally collects annotation info about classes inside a given directory. |
| 22 | + * Collects: |
| 23 | + * - @Script annotations for invocation |
| 24 | + * - Overridden methods |
| 25 | + * - Annotations on methods and it's data |
| 26 | + * - Processes annotation wildcards |
| 27 | + */ |
| 28 | +abstract class ScriptMetadataTask : DefaultTask() { |
| 29 | + |
| 30 | + private enum class WildcardType { |
| 31 | + NpcId, |
| 32 | + InterfaceId, |
| 33 | + ComponentId, |
| 34 | + ObjectId, |
| 35 | + ItemId, |
| 36 | + NpcOption, |
| 37 | + InterfaceOption, |
| 38 | + FloorItemOption, |
| 39 | + ObjectOption, |
| 40 | + ItemOption, |
| 41 | + } |
| 42 | + |
| 43 | + // List of annotation names and their parameters |
| 44 | + private val annotations: Map<String, List<Pair<String, WildcardType>>> = mapOf() |
| 45 | + |
| 46 | + @get:Incremental |
| 47 | + @get:InputFiles |
| 48 | + @get:PathSensitive(PathSensitivity.RELATIVE) |
| 49 | + abstract val inputDirectory: DirectoryProperty |
| 50 | + |
| 51 | + @get:Internal |
| 52 | + abstract var dataDirectory: File |
| 53 | + |
| 54 | + @get:Internal |
| 55 | + abstract var resourceDirectory: File |
| 56 | + |
| 57 | + @get:OutputFile |
| 58 | + abstract var scriptsFile: File |
| 59 | + |
| 60 | + init { |
| 61 | + description = "Analyzes Kotlin files and extracts annotation information" |
| 62 | + group = "metadata" |
| 63 | + } |
| 64 | + |
| 65 | + @TaskAction |
| 66 | + fun execute(inputChanges: InputChanges) { |
| 67 | + val start = System.currentTimeMillis() |
| 68 | + |
| 69 | + val npcIds = mutableSetOf<String>() |
| 70 | + val itemIds = mutableSetOf<String>() |
| 71 | + val objectIds = mutableSetOf<String>() |
| 72 | + val interfaceIds = mutableSetOf<String>() |
| 73 | + val componentIds = mutableSetOf<String>() |
| 74 | + collectIds(npcIds, itemIds, objectIds, interfaceIds, componentIds) |
| 75 | + val options = System.currentTimeMillis() |
| 76 | + val npcOptions = loadOptions("npc-options") |
| 77 | + val itemOptions = loadOptions("item-options") |
| 78 | + val floorItemOptions = loadOptions("floor-item-options") |
| 79 | + val objectOptions = loadOptions("object-options") |
| 80 | + val interfaceOptions = loadOptions("interface-options") |
| 81 | + println("Loaded ${npcOptions.size} npc, ${itemOptions.size} item, ${floorItemOptions.size} floor item, ${objectOptions.size} object, ${interfaceOptions.size} interface options in ${System.currentTimeMillis() - options}ms") |
| 82 | + |
| 83 | + val lines: MutableList<String> |
| 84 | + if (!inputChanges.isIncremental) { |
| 85 | + // Clean output for non-incremental runs |
| 86 | + scriptsFile.delete() |
| 87 | + logger.info("Non-incremental run: analyzing all files") |
| 88 | + lines = mutableListOf() |
| 89 | + } else { |
| 90 | + lines = if (scriptsFile.exists()) scriptsFile.readLines().toMutableList() else mutableListOf() |
| 91 | + } |
| 92 | + val disposable = Disposer.newDisposable() |
| 93 | + val environment = createKotlinEnvironment(disposable) |
| 94 | + var scripts = 0 |
| 95 | + var methodCount = 0 |
| 96 | + var annotationCount = 0 |
| 97 | + val instance = PsiManager.getInstance(environment.project) |
| 98 | + val scriptClasses = mutableListOf<Pair<KtClass, String>>() |
| 99 | + for (change in inputChanges.getFileChanges(inputDirectory)) { |
| 100 | + val file = change.file |
| 101 | + if (change.changeType == ChangeType.REMOVED) { |
| 102 | + removeName(lines, file.nameWithoutExtension) |
| 103 | + continue |
| 104 | + } |
| 105 | + if (!file.isFile || !file.name.endsWith(".kt")) { |
| 106 | + continue |
| 107 | + } |
| 108 | + val localFile = environment.findLocalFile(file.path) |
| 109 | + if (localFile == null) { |
| 110 | + println("Local file not found: ${file.path}") |
| 111 | + continue |
| 112 | + } |
| 113 | + val psiFile: KtFile = instance.findFile(localFile) as KtFile |
| 114 | + |
| 115 | + val classes = psiFile.collectDescendantsOfType<KtClass>() |
| 116 | + val packageName = psiFile.packageFqName.asString() |
| 117 | + if (change.changeType == ChangeType.MODIFIED) { |
| 118 | + for (name in classes.map { it.name }) { |
| 119 | + if (!lines.removeIf { it.endsWith("$packageName.$name") }) { |
| 120 | + removeName(lines, name) |
| 121 | + } |
| 122 | + } |
| 123 | + } |
| 124 | + if (change.changeType == ChangeType.MODIFIED || change.changeType == ChangeType.ADDED) { |
| 125 | + for (ktClass in classes) { |
| 126 | + val className = ktClass.name ?: "Anonymous" |
| 127 | + if (ktClass.annotationEntries.any { anno -> anno.shortName!!.asString() == "Script" }) { |
| 128 | + scriptClasses.add(ktClass to "$packageName.$className") |
| 129 | + } |
| 130 | + } |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + for ((ktClass, packagePath) in scriptClasses) { |
| 135 | + val methods = ktClass.declarations.filterIsInstance<KtNamedFunction>().filter { it.hasModifier(KtTokens.OVERRIDE_KEYWORD) } |
| 136 | + scripts++ |
| 137 | + if (methods.isEmpty()) { |
| 138 | + lines.add(packagePath) |
| 139 | + continue |
| 140 | + } |
| 141 | + for (method in methods) { |
| 142 | + methodCount++ |
| 143 | + val returnType = method.typeReference |
| 144 | + val signature = "${method.name}(${method.valueParameters.joinToString(",") { param -> param.typeReference!!.getTypeText() }})${if (returnType == null) "" else ":${returnType.getTypeText()}"}" |
| 145 | + val entries = method.annotationEntries |
| 146 | + if (entries.isEmpty()) { |
| 147 | + lines.add("${signature}|$packagePath") |
| 148 | + continue |
| 149 | + } |
| 150 | + for (annotation in entries) { |
| 151 | + val annotationName = annotation.shortName?.asString() ?: "" |
| 152 | + val info = annotations[annotationName] ?: error("Annotation $annotationName metadata not found. Make sure your annotation is registered in ScriptMetadataTask.kt") |
| 153 | + val params = Array<MutableList<String>>(info.size) { mutableListOf() } |
| 154 | + // Resolve annotation field names |
| 155 | + var index = 0 |
| 156 | + for (arg in annotation.valueArguments) { |
| 157 | + val name = arg.getArgumentName()?.asName?.asString() |
| 158 | + val value = arg.getArgumentExpression()?.text?.trim('"') ?: "" |
| 159 | + val idx = if (name != null) info.indexOfFirst { it.first == name } else index++ |
| 160 | + params[idx].add(value) |
| 161 | + } |
| 162 | + for (i in info.indices) { |
| 163 | + val value = params[i].first() |
| 164 | + // Expand wildcards into matches |
| 165 | + if (value.contains("*") || value.contains("#")) { |
| 166 | + val set = when (info[i].second) { |
| 167 | + WildcardType.NpcId -> npcIds |
| 168 | + WildcardType.InterfaceId -> interfaceIds |
| 169 | + WildcardType.ComponentId -> componentIds |
| 170 | + WildcardType.ObjectId -> objectIds |
| 171 | + WildcardType.ItemId -> itemIds |
| 172 | + WildcardType.NpcOption -> npcOptions |
| 173 | + WildcardType.InterfaceOption -> interfaceOptions |
| 174 | + WildcardType.FloorItemOption -> floorItemOptions |
| 175 | + WildcardType.ObjectOption -> objectOptions |
| 176 | + WildcardType.ItemOption -> itemOptions |
| 177 | + } |
| 178 | + val matches = set.filter { wildcardEquals(value, it) } |
| 179 | + if (matches.isEmpty()) { |
| 180 | + error("No matches for wildcard '${value}' in $packagePath ${annotation.text}") |
| 181 | + } |
| 182 | + params[i].removeAt(0) |
| 183 | + params[i].addAll(matches) |
| 184 | + } |
| 185 | + } |
| 186 | + generateCombinations(params) { args -> |
| 187 | + annotationCount++ |
| 188 | + lines.add("@${annotation.shortName}|${args.joinToString(":")}|$signature|$packagePath") |
| 189 | + } |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | + scriptsFile.writeText(lines.joinToString("\n")) |
| 194 | + disposable.dispose() |
| 195 | + println("Metadata for $scripts scripts, $methodCount methods and $annotationCount annotations collected in ${System.currentTimeMillis() - start} ms") |
| 196 | + } |
| 197 | + |
| 198 | + private fun generateCombinations(arrays: Array<MutableList<String>>, index: Int = 0, current: MutableList<String> = mutableListOf(), call: (List<String>) -> Unit) { |
| 199 | + if (index == arrays.size) { |
| 200 | + call.invoke(current) |
| 201 | + return |
| 202 | + } |
| 203 | + val currentArray = arrays[index] |
| 204 | + for (element in currentArray) { |
| 205 | + current.add(element) |
| 206 | + generateCombinations(arrays, index + 1, current, call) |
| 207 | + current.removeAt(current.size - 1) |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + private fun loadOptions(type: String): Set<String> { |
| 212 | + return ScriptMetadataTask::class.java.getResource("$type.txt")!!.readText().lines().toSet() |
| 213 | + } |
| 214 | + |
| 215 | + private fun collectIds( |
| 216 | + npcIds: MutableSet<String>, |
| 217 | + itemIds: MutableSet<String>, |
| 218 | + objectIds: MutableSet<String>, |
| 219 | + interfaceIds: MutableSet<String>, |
| 220 | + componentIds: MutableSet<String>, |
| 221 | + ) { |
| 222 | + val start = System.currentTimeMillis() |
| 223 | + for (file in dataDirectory.walkTopDown()) { |
| 224 | + if (!file.isFile) { |
| 225 | + continue |
| 226 | + } |
| 227 | + if (file.name.endsWith(".npcs.toml")) { |
| 228 | + for (line in file.readLines()) { |
| 229 | + if (line.startsWith('[')) { |
| 230 | + npcIds.add(line.substringBefore(']').trim('[')) |
| 231 | + } |
| 232 | + } |
| 233 | + } else if (file.name.endsWith(".items.toml")) { |
| 234 | + for (line in file.readLines()) { |
| 235 | + if (line.startsWith('[')) { |
| 236 | + itemIds.add(line.substringBefore(']').trim('[')) |
| 237 | + } |
| 238 | + } |
| 239 | + } else if (file.name.endsWith(".objs.toml")) { |
| 240 | + for (line in file.readLines()) { |
| 241 | + if (line.startsWith('[')) { |
| 242 | + objectIds.add(line.substringBefore(']').trim('[')) |
| 243 | + } |
| 244 | + } |
| 245 | + } else if (file.name.endsWith(".ifaces.toml")) { |
| 246 | + for (line in file.readLines()) { |
| 247 | + if (line.startsWith('[')) { |
| 248 | + val key = line.substringBefore(']').trim('[') |
| 249 | + if (key.contains(".")) { |
| 250 | + componentIds.add(key.substringAfter('.')) |
| 251 | + } else { |
| 252 | + interfaceIds.add(key) |
| 253 | + } |
| 254 | + } |
| 255 | + } |
| 256 | + } |
| 257 | + } |
| 258 | + println("Collected ${npcIds.size} npcs, ${itemIds.size} items, ${objectIds.size} objects, ${interfaceIds.size} interfaces, ${componentIds.size} components in ${System.currentTimeMillis() - start}ms") |
| 259 | + } |
| 260 | + |
| 261 | + private fun removeName(scriptsList: MutableList<String>, name: String?) { |
| 262 | + if (scriptsList.filter { it.endsWith(".$name") }.map { it.split("|").last() }.distinct().count() > 1) { |
| 263 | + error("Deletion failed due to duplicate script names: ${scriptsList.filter { it.endsWith(".$name") }.map { it.split("|").last() }.distinct()}. Please update scripts.txt or run `gradle cleanScriptMetadata`.") |
| 264 | + } |
| 265 | + scriptsList.removeIf { it.endsWith(".$name") } |
| 266 | + } |
| 267 | + |
| 268 | + private fun createKotlinEnvironment(disposable: Disposable): KotlinCoreEnvironment = KotlinCoreEnvironment.createForProduction( |
| 269 | + disposable, |
| 270 | + CompilerConfiguration.EMPTY, |
| 271 | + EnvironmentConfigFiles.JVM_CONFIG_FILES, |
| 272 | + ) |
| 273 | + |
| 274 | + |
| 275 | + private fun wildcardEquals(wildcard: String, other: String): Boolean { |
| 276 | + if (wildcard == "*") { |
| 277 | + return true |
| 278 | + } |
| 279 | + var wildIndex = 0 |
| 280 | + var otherIndex = 0 |
| 281 | + var starIndex = -1 |
| 282 | + var matchIndex = -1 |
| 283 | + |
| 284 | + while (otherIndex < other.length) { |
| 285 | + when { |
| 286 | + wildIndex < wildcard.length && (wildcard[wildIndex] == '#' && other[otherIndex].isDigit()) -> { |
| 287 | + wildIndex++ |
| 288 | + otherIndex++ |
| 289 | + } |
| 290 | + wildIndex < wildcard.length && wildcard[wildIndex] == '*' -> { |
| 291 | + starIndex = wildIndex |
| 292 | + matchIndex = otherIndex |
| 293 | + wildIndex++ |
| 294 | + } |
| 295 | + wildIndex < wildcard.length && wildcard[wildIndex] == other[otherIndex] -> { |
| 296 | + wildIndex++ |
| 297 | + otherIndex++ |
| 298 | + } |
| 299 | + starIndex != -1 -> { |
| 300 | + wildIndex = starIndex + 1 |
| 301 | + matchIndex++ |
| 302 | + otherIndex = matchIndex |
| 303 | + } |
| 304 | + else -> return false |
| 305 | + } |
| 306 | + } |
| 307 | + |
| 308 | + while (wildIndex < wildcard.length && wildcard[wildIndex] == '*') { |
| 309 | + wildIndex++ |
| 310 | + } |
| 311 | + |
| 312 | + return wildIndex == wildcard.length && otherIndex == other.length |
| 313 | + } |
| 314 | + |
| 315 | +} |
0 commit comments