Skip to content

Commit 42622d3

Browse files
committed
feat(gradle): compile and check Lua sources
1 parent 65977d0 commit 42622d3

14 files changed

Lines changed: 1491 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 java.io.Closeable
12+
import java.io.File
13+
import java.net.URLClassLoader
14+
import java.util.zip.ZipFile
15+
16+
/**
17+
* What the host project's classpath holds, as the build sees it.
18+
*
19+
* Classes are asked for by loading them; packages are read off the entries,
20+
* since no loader can be asked whether a package exists.
21+
*/
22+
internal class MawuClasspath(files: Collection<File>) : Closeable {
23+
24+
private val loader = URLClassLoader(
25+
files.map { it.toURI().toURL() }.toTypedArray(),
26+
ClassLoader.getPlatformClassLoader()
27+
)
28+
29+
private val packages: Set<String> by lazy { packagesOf(files) + platformPackages() }
30+
31+
private val resolved = HashMap<String, Class<*>?>()
32+
33+
/** Whether [name] is a class here. */
34+
fun hasClass(name: String): Boolean = classOf(name) != null
35+
36+
/** The class [name] stands for, or null. A walk asks for the same names often. */
37+
fun classOf(name: String): Class<*>? = resolved.getOrPut(name) {
38+
try {
39+
Class.forName(name, false, loader)
40+
} catch (e: ClassNotFoundException) {
41+
null
42+
} catch (e: LinkageError) {
43+
null
44+
}
45+
}
46+
47+
/** Whether [name] is a package holding at least one class. */
48+
fun hasPackage(name: String): Boolean = name in packages
49+
50+
override fun close() = loader.close()
51+
52+
private fun packagesOf(files: Collection<File>): Set<String> {
53+
val found = mutableSetOf<String>()
54+
for (file in files) {
55+
when {
56+
file.isDirectory -> file.walkTopDown()
57+
.filter { it.isFile && it.extension == "class" }
58+
.forEach { found += packageOf(it.relativeTo(file).invariantSeparatorsPath) }
59+
60+
file.isFile && file.extension in ARCHIVES -> readArchive(file, found)
61+
}
62+
}
63+
return found
64+
}
65+
66+
private fun readArchive(file: File, found: MutableSet<String>) {
67+
try {
68+
ZipFile(file).use { archive ->
69+
for (entry in archive.entries()) {
70+
if (!entry.isDirectory && entry.name.endsWith(".class")) {
71+
found += packageOf(entry.name)
72+
}
73+
}
74+
}
75+
} catch (e: Exception) {
76+
// A jar the build cannot read says nothing about what a script named.
77+
}
78+
}
79+
80+
/** Every package of an entry path, including the prefixes leading to it. */
81+
private fun packageOf(path: String): Set<String> {
82+
val segments = path.substringBeforeLast('/', "").split('/').filter { it.isNotEmpty() }
83+
return segments.indices.map { segments.take(it + 1).joinToString(".") }.toSet()
84+
}
85+
86+
private operator fun MutableSet<String>.plusAssign(names: Set<String>) {
87+
addAll(names)
88+
}
89+
90+
private fun platformPackages(): Set<String> =
91+
ModuleLayer.boot().modules()
92+
.flatMap { it.packages }
93+
.flatMap { name ->
94+
val segments = name.split('.')
95+
segments.indices.map { segments.take(it + 1).joinToString(".") }
96+
}
97+
.toSet()
98+
99+
private companion object {
100+
val ARCHIVES = setOf("jar", "zip")
101+
}
102+
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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

Comments
 (0)