|
| 1 | +/* |
| 2 | + * Mawu |
| 3 | + * https://github.com/BluevaDevelopment/Mawu |
| 4 | + * |
| 5 | + * Copyright (c) 2026 Blueva Development |
| 6 | + * |
| 7 | + * SPDX-License-Identifier: GPL-3.0-only |
| 8 | + */ |
| 9 | +package net.blueva.mawu.gradle |
| 10 | + |
| 11 | +import net.blueva.luak.LuaError |
| 12 | +import net.blueva.luak.Prototype |
| 13 | +import net.blueva.luak.compiler.DumpState |
| 14 | +import net.blueva.luak.compiler.LuaC |
| 15 | +import net.blueva.luak.lib.jvm.JvmPlatform |
| 16 | +import net.blueva.mawu.bridge.MawuJava |
| 17 | +import net.blueva.mawu.runtime.MawuLayout |
| 18 | +import org.gradle.api.DefaultTask |
| 19 | +import org.gradle.api.GradleException |
| 20 | +import org.gradle.api.file.ConfigurableFileCollection |
| 21 | +import org.gradle.api.file.DirectoryProperty |
| 22 | +import org.gradle.api.file.FileSystemOperations |
| 23 | +import org.gradle.api.provider.Property |
| 24 | +import org.gradle.api.provider.SetProperty |
| 25 | +import org.gradle.api.tasks.CacheableTask |
| 26 | +import org.gradle.api.tasks.Classpath |
| 27 | +import org.gradle.api.tasks.Input |
| 28 | +import org.gradle.api.tasks.InputFiles |
| 29 | +import org.gradle.api.tasks.OutputDirectory |
| 30 | +import org.gradle.api.tasks.PathSensitive |
| 31 | +import org.gradle.api.tasks.PathSensitivity |
| 32 | +import org.gradle.api.tasks.TaskAction |
| 33 | +import org.gradle.work.ChangeType |
| 34 | +import org.gradle.work.Incremental |
| 35 | +import org.gradle.work.InputChanges |
| 36 | +import java.io.BufferedOutputStream |
| 37 | +import java.io.File |
| 38 | +import java.net.URLClassLoader |
| 39 | +import javax.inject.Inject |
| 40 | + |
| 41 | +/** |
| 42 | + * Compiles Lua sources into Luak prototypes, laid out as resources. |
| 43 | + * |
| 44 | + * Nothing here emits JVM bytecode: what lands in the jar is Lua bytecode plus |
| 45 | + * the index the runtime reads to find it. |
| 46 | + */ |
| 47 | +@CacheableTask |
| 48 | +abstract class MawuCompile : DefaultTask() { |
| 49 | + |
| 50 | + /** Source directories holding `.lua` files. */ |
| 51 | + @get:InputFiles |
| 52 | + @get:Incremental |
| 53 | + @get:PathSensitive(PathSensitivity.RELATIVE) |
| 54 | + abstract val source: ConfigurableFileCollection |
| 55 | + |
| 56 | + /** Whether to drop line numbers and local names from the compiled output. */ |
| 57 | + @get:Input |
| 58 | + abstract val stripDebugInfo: Property<Boolean> |
| 59 | + |
| 60 | + /** What to do about a global a script reads that nothing defines. */ |
| 61 | + @get:Input |
| 62 | + abstract val undefinedGlobals: Property<MawuSeverity> |
| 63 | + |
| 64 | + /** Globals the host installs at runtime, which scripts may read freely. */ |
| 65 | + @get:Input |
| 66 | + abstract val knownGlobals: SetProperty<String> |
| 67 | + |
| 68 | + /** What to do about an import naming a class the classpath does not have. */ |
| 69 | + @get:Input |
| 70 | + abstract val unknownClasses: Property<MawuSeverity> |
| 71 | + |
| 72 | + /** Where an imported class is looked for. */ |
| 73 | + @get:Classpath |
| 74 | + abstract val classpath: ConfigurableFileCollection |
| 75 | + |
| 76 | + /** Resource root the compiled scripts and their index are written to. */ |
| 77 | + @get:OutputDirectory |
| 78 | + abstract val destinationDirectory: DirectoryProperty |
| 79 | + |
| 80 | + @get:Inject |
| 81 | + protected abstract val fileSystem: FileSystemOperations |
| 82 | + |
| 83 | + @TaskAction |
| 84 | + fun compile(changes: InputChanges) { |
| 85 | + val outputRoot = destinationDirectory.get().asFile |
| 86 | + if (!changes.isIncremental) { |
| 87 | + fileSystem.delete { it.delete(outputRoot) } |
| 88 | + } |
| 89 | + |
| 90 | + // The index needs every id either way. |
| 91 | + val sources = collectSources() |
| 92 | + val stale = if (changes.isIncremental) removedIds(changes) else emptySet() |
| 93 | + val outdated = if (changes.isIncremental) changedIds(changes) else sources.keys |
| 94 | + |
| 95 | + for (id in stale) { |
| 96 | + outputOf(id).delete() |
| 97 | + } |
| 98 | + |
| 99 | + val failures = mutableListOf<String>() |
| 100 | + val compiled = LinkedHashMap<String, Prototype>() |
| 101 | + for (id in outdated) { |
| 102 | + val file = sources[id] ?: continue |
| 103 | + try { |
| 104 | + val prototype = compile(id, file) |
| 105 | + write(id, prototype) |
| 106 | + compiled[id] = prototype |
| 107 | + } catch (e: LuaError) { |
| 108 | + failures += describe(id, file, e) |
| 109 | + } |
| 110 | + } |
| 111 | + if (failures.isNotEmpty()) { |
| 112 | + val header = if (failures.size == 1) { |
| 113 | + "Lua compilation failed:" |
| 114 | + } else { |
| 115 | + "Lua compilation failed in ${failures.size} sources:" |
| 116 | + } |
| 117 | + throw GradleException(failures.joinToString("\n", prefix = "$header\n")) |
| 118 | + } |
| 119 | + |
| 120 | + val prototypes = allPrototypes(sources, compiled) |
| 121 | + checkGlobals(sources, prototypes) |
| 122 | + checkReferences(sources, prototypes) |
| 123 | + |
| 124 | + if (sources.isNotEmpty()) { |
| 125 | + writeIndex(outputRoot, sources.keys) |
| 126 | + } |
| 127 | + logger.info("Mawu compiled {} of {} Lua scripts into {}", outdated.size, sources.size, outputRoot) |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * Reports a global that nothing in this source set, the standard library |
| 132 | + * or the host defines. Every script is looked at, since a global defined |
| 133 | + * in one file is read in another. |
| 134 | + */ |
| 135 | + private fun checkGlobals(sources: Map<String, File>, prototypes: Map<String, Prototype>) { |
| 136 | + val severity = undefinedGlobals.get() |
| 137 | + if (severity == MawuSeverity.IGNORE || prototypes.isEmpty()) return |
| 138 | + |
| 139 | + val usages = prototypes.mapValues { MawuGlobals.of(it.value) } |
| 140 | + val defined = standardGlobals() + knownGlobals.get() + usages.values.flatMap { it.writes } |
| 141 | + val reported = usages.flatMap { (id, usage) -> |
| 142 | + usage.reads |
| 143 | + .filterNot { it.name in defined } |
| 144 | + .distinct() |
| 145 | + .map { "${sources.getValue(id).path}:${it.line}: undefined global '${it.name}'" } |
| 146 | + } |
| 147 | + report(severity, reported, "Undefined globals:") |
| 148 | + } |
| 149 | + |
| 150 | + /** |
| 151 | + * Reports a class, package or member a script names that the classpath |
| 152 | + * does not have. A name only known at runtime is what the severity is for. |
| 153 | + */ |
| 154 | + private fun checkReferences(sources: Map<String, File>, prototypes: Map<String, Prototype>) { |
| 155 | + val severity = unknownClasses.get() |
| 156 | + if (severity == MawuSeverity.IGNORE || prototypes.isEmpty()) return |
| 157 | + |
| 158 | + MawuClasspath(classpath.files).use { available -> |
| 159 | + val reported = mutableListOf<String>() |
| 160 | + for ((id, prototype) in prototypes) { |
| 161 | + val path = sources.getValue(id).path |
| 162 | + val references = MawuReferences.of(prototype) { available.classOf(it) } |
| 163 | + |
| 164 | + references.classes |
| 165 | + .filterNot { available.hasClass(it.name) } |
| 166 | + .forEach { reported += "$path:${it.line}: no class named '${it.name}' on the classpath" } |
| 167 | + |
| 168 | + references.names |
| 169 | + .filterNot { available.hasClass(it.name) || available.hasPackage(it.name) } |
| 170 | + .forEach { reported += "$path:${it.line}: no class or package named '${it.name}'" } |
| 171 | + |
| 172 | + for (member in references.members) { |
| 173 | + val owner = available.classOf(member.owner) ?: continue |
| 174 | + if (!hasMember(owner, member.member)) { |
| 175 | + reported += "$path:${member.line}: '${member.owner}' has no member '${member.member}'" |
| 176 | + } |
| 177 | + } |
| 178 | + } |
| 179 | + report(severity, reported, "Unknown classes:") |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + /** Whether Lua would find [name] on [owner], the way Luak looks for it. */ |
| 184 | + private fun hasMember(owner: Class<*>, name: String): Boolean = |
| 185 | + name == "new" || |
| 186 | + owner.methods.any { it.name == name } || |
| 187 | + owner.fields.any { it.name == name } || |
| 188 | + owner.classes.any { it.simpleName == name } |
| 189 | + |
| 190 | + /** Fails or warns, depending on what the build asked for. */ |
| 191 | + private fun report(severity: MawuSeverity, diagnostics: List<String>, header: String) { |
| 192 | + if (diagnostics.isEmpty()) return |
| 193 | + if (severity == MawuSeverity.ERROR) { |
| 194 | + throw GradleException(diagnostics.joinToString("\n", prefix = "$header\n")) |
| 195 | + } |
| 196 | + diagnostics.forEach { logger.warn("w: {}", it) } |
| 197 | + } |
| 198 | + |
| 199 | + /** Every script's compiled form, parsing the ones this run did not compile. */ |
| 200 | + private fun allPrototypes(sources: Map<String, File>, compiled: Map<String, Prototype>): Map<String, Prototype> { |
| 201 | + if (undefinedGlobals.get() == MawuSeverity.IGNORE && unknownClasses.get() == MawuSeverity.IGNORE) { |
| 202 | + return emptyMap() |
| 203 | + } |
| 204 | + val prototypes = LinkedHashMap<String, Prototype>(compiled) |
| 205 | + for ((id, file) in sources) { |
| 206 | + if (id in prototypes) continue |
| 207 | + prototypes[id] = try { |
| 208 | + compile(id, file) |
| 209 | + } catch (e: LuaError) { |
| 210 | + continue |
| 211 | + } |
| 212 | + } |
| 213 | + return prototypes |
| 214 | + } |
| 215 | + |
| 216 | + /** Every name a script finds in its environment before the host adds any. */ |
| 217 | + private fun standardGlobals(): Set<String> { |
| 218 | + val globals = JvmPlatform.standardGlobals() |
| 219 | + MawuJava.install(globals) |
| 220 | + return globals.keys().mapNotNull { it?.tojstring() }.toSet() + "_ENV" |
| 221 | + } |
| 222 | + |
| 223 | + /** |
| 224 | + * The compiler's message against the file it belongs to. Lua reports the |
| 225 | + * chunk name; the real path in its place is a line an IDE can open. |
| 226 | + */ |
| 227 | + private fun describe(id: String, file: File, error: LuaError): String { |
| 228 | + val message = error.message ?: "failed to compile" |
| 229 | + val chunk = "$id.lua:" |
| 230 | + return if (message.startsWith(chunk)) { |
| 231 | + "${file.path}:${message.removePrefix(chunk)}" |
| 232 | + } else { |
| 233 | + "${file.path}: $message" |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + /** Ids whose source was added or modified since the last run. */ |
| 238 | + private fun changedIds(changes: InputChanges): Set<String> = |
| 239 | + changes.getFileChanges(source) |
| 240 | + .filter { it.changeType != ChangeType.REMOVED && it.file.isFile } |
| 241 | + .map { MawuLayout.idOf(it.normalizedPath) } |
| 242 | + .toSet() |
| 243 | + |
| 244 | + /** Ids whose source is gone, and whose compiled form has to go with it. */ |
| 245 | + private fun removedIds(changes: InputChanges): Set<String> = |
| 246 | + changes.getFileChanges(source) |
| 247 | + .filter { it.changeType == ChangeType.REMOVED } |
| 248 | + .map { MawuLayout.idOf(it.normalizedPath) } |
| 249 | + .toSet() |
| 250 | + |
| 251 | + /** Every source file by script id, sorted so the output is reproducible. */ |
| 252 | + private fun collectSources(): Map<String, File> { |
| 253 | + val found = sortedMapOf<String, File>() |
| 254 | + source.asFileTree.matching { it.include("**/*.lua") }.visit { details -> |
| 255 | + if (!details.isDirectory) { |
| 256 | + val id = MawuLayout.idOf(details.relativePath.pathString) |
| 257 | + val previous = found.put(id, details.file) |
| 258 | + if (previous != null) { |
| 259 | + throw GradleException( |
| 260 | + "Two Lua sources compile to the same script id '$id': $previous and ${details.file}" |
| 261 | + ) |
| 262 | + } |
| 263 | + } |
| 264 | + } |
| 265 | + return found |
| 266 | + } |
| 267 | + |
| 268 | + private fun compile(id: String, file: File): Prototype = |
| 269 | + file.inputStream().buffered().use { LuaC.instance.compile(it, MawuLayout.chunkName(id)) } |
| 270 | + |
| 271 | + private fun outputOf(id: String): File = |
| 272 | + destinationDirectory.get().file(MawuLayout.resourcePath(id)).asFile |
| 273 | + |
| 274 | + private fun write(id: String, prototype: Prototype) { |
| 275 | + val target = outputOf(id) |
| 276 | + target.parentFile.mkdirs() |
| 277 | + BufferedOutputStream(target.outputStream()).use { |
| 278 | + DumpState.dump(prototype, it, stripDebugInfo.get()) |
| 279 | + } |
| 280 | + } |
| 281 | + |
| 282 | + private fun writeIndex(outputRoot: File, ids: Collection<String>) { |
| 283 | + val index = File(outputRoot, MawuLayout.INDEX) |
| 284 | + index.parentFile.mkdirs() |
| 285 | + index.writeText(ids.joinToString(separator = "\n", postfix = "\n")) |
| 286 | + } |
| 287 | +} |
0 commit comments