From d010fc467f7ce2051c3f227d43e307064ee57c2c Mon Sep 17 00:00:00 2001 From: GT Date: Fri, 18 Sep 2026 17:17:37 +0200 Subject: [PATCH 1/7] Generate one source file and static resource per template Each renderer now carries its own STATIC bytes and references neither the registry nor other renderers, so an unchanged template regenerates byte-identical files and Gradle's incremental Java compilation recompiles only the renderer whose template changed plus the registry. Co-Authored-By: Claude Fable 5.1 --- .../beint/thim/compiler/RendererGenerator.kt | 19 +++++--- .../no/beint/thim/compiler/ThimProcessor.kt | 44 +++++++++++++------ 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt index 4de0c84..b2bc103 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt @@ -9,10 +9,17 @@ import com.google.devtools.ksp.symbol.Nullability import java.io.ByteArrayOutputStream import java.nio.charset.StandardCharsets +/** + * One renderer class and its own static UTF-8 content. Keeping each template's output + * independent of every other template makes the generated file for an unchanged + * template byte-identical between builds, so Gradle's incremental Java compilation + * recompiles only the renderer whose template changed. + */ internal data class CompiledTemplate( val model: KSClassDeclaration, val rendererName: String, val source: String, + val staticContent: ByteArray, val usesRequestDataValues: Boolean, ) @@ -32,8 +39,6 @@ internal class StaticContent { internal class RendererGenerator( private val catalog: MessageCatalog, private val routes: RouteCatalog, - private val staticContent: StaticContent, - private val registryName: String, private val strictModels: Boolean = false, ) { private var generatedVariable = 0 @@ -52,7 +57,8 @@ internal class RendererGenerator( fun compile(templateName: String, model: KSClassDeclaration, nodes: List): CompiledTemplate { val modelName = model.qualifiedName?.asString() ?: error("$templateName: model must have a qualified name") val rendererName = modelName.replace(Regex("[^A-Za-z0-9_]"), "_") + "ThimRenderer" - val code = CodeWriter(staticContent, registryName) + val staticContent = StaticContent() + val code = CodeWriter(staticContent) val locales = if (usesMessages(nodes)) { catalog.supportedLocales.filterTo(linkedSetOf()) { it != catalog.defaultLocale } } else { @@ -75,6 +81,8 @@ internal class RendererGenerator( code.line("final class $rendererName {") code.indent { + code.line("static final byte[] STATIC = HtmlOutput.resource($rendererName.class, \"$rendererName.bin\");") + code.line() code.line("private $rendererName() {}") code.line() code.line("static void render($modelName model, RenderContext context, HtmlOutput output) throws IOException {") @@ -125,7 +133,7 @@ internal class RendererGenerator( } code.line("}") val source = code.toString() - return CompiledTemplate(model, rendererName, source, "context.requestDataValues()" in source) + return CompiledTemplate(model, rendererName, source, staticContent.bytes(), "context.requestDataValues()" in source) } private fun renderNodes(nodes: List, scope: Scope, code: CodeWriter, context: String) { @@ -1422,7 +1430,6 @@ internal class RendererGenerator( private class CodeWriter( private val staticContent: StaticContent, - private val registryName: String, ) { private val output = StringBuilder() private val pending = StringBuilder() @@ -1464,7 +1471,7 @@ internal class RendererGenerator( if (pending.isEmpty()) return val range = staticContent.append(pending.toString()) output.append(" ".repeat(depth)) - .append("output.raw($registryName.STATIC, ") + .append("output.raw(STATIC, ") .append(range.first) .append(", ") .append(range.last - range.first + 1) diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt index 4170acd..d7c6c4a 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt @@ -110,8 +110,7 @@ private class ThimProcessor( } else { RouteCatalog(emptyList(), emptyList(), extractedRoutes.files) } - val staticContent = StaticContent() - val generator = RendererGenerator(catalog, routeCatalog, staticContent, registryName, strictModels) + val generator = RendererGenerator(catalog, routeCatalog, strictModels) val compiled = templates.map { template -> generator.compile(template.name, template.model, template.nodes) } @@ -126,7 +125,7 @@ private class ThimProcessor( completed = true return emptyList() } - generate(compiled, staticContent.bytes(), extractedRoutes) + generate(compiled, extractedRoutes) if (catalog.definitions().isNotEmpty()) { val files = (compiled.mapNotNull { it.model.containingFile } + extractedRoutes.files).distinct().toTypedArray() generateMessages(catalog, files) @@ -188,15 +187,37 @@ private class ThimProcessor( diagnostic("THIM-MODEL-UNUSED-PROPERTY", null, unused.joinToString("; ")) } - private fun generate(compiled: List, staticContent: ByteArray, routeCatalog: RouteCatalog) { + /** + * Each template gets its own source file and static resource. A renderer references + * only its own content, never the registry or another renderer, so an unchanged + * template regenerates byte-identical files and Gradle's incremental Java compilation + * skips it. The registry is the only file that references every renderer. + */ + private fun generate(compiled: List, routeCatalog: RouteCatalog) { val files = (compiled.mapNotNull { it.model.containingFile } + routeCatalog.files).distinct().toTypedArray() val dependencies = Dependencies(aggregating = true, *files) - codeGenerator.createNewFile( - dependencies = dependencies, - packageName = generatedPackage, - fileName = registryName, - extensionName = "bin", - ).use { it.write(staticContent) } + compiled.forEach { template -> + codeGenerator.createNewFile( + dependencies = dependencies, + packageName = generatedPackage, + fileName = template.rendererName, + extensionName = "bin", + ).use { it.write(template.staticContent) } + codeGenerator.createNewFile( + dependencies = dependencies, + packageName = generatedPackage, + fileName = template.rendererName, + extensionName = "java", + ).bufferedWriter(StandardCharsets.UTF_8).use { output -> + output.appendLine("package $generatedPackage;") + output.appendLine() + output.appendLine("import java.io.IOException;") + output.appendLine("import no.beint.thim.HtmlOutput;") + output.appendLine("import no.beint.thim.RenderContext;") + output.appendLine() + output.append(template.source) + } + } codeGenerator.createNewFile( dependencies = dependencies, packageName = generatedPackage, @@ -210,10 +231,7 @@ private class ThimProcessor( output.appendLine("import no.beint.thim.RenderContext;") output.appendLine("import no.beint.thim.TemplateSet;") output.appendLine() - compiled.forEach { output.append(it.source).appendLine() } output.appendLine("public final class $registryName implements TemplateSet {") - output.appendLine(" static final byte[] STATIC = HtmlOutput.resource($registryName.class, \"$registryName.bin\");") - output.appendLine() output.appendLine(" // Exact page-model classes resolve in constant time; the instanceof chain in render only") output.appendLine(" // serves subclasses of open models, matching the previous linear dispatch.") output.appendLine(" private static final java.util.Map, Integer> INDEX = index();") From f0406f21a83d3ac1583ddff0b5e3d1b4a7a2af34 Mon Sep 17 00:00:00 2001 From: GT Date: Fri, 18 Sep 2026 17:20:27 +0200 Subject: [PATCH 2/7] Rename generated renderer classes so the layout change survives incremental javac Co-Authored-By: Claude Fable 5.1 --- .../main/kotlin/no/beint/thim/compiler/RendererGenerator.kt | 6 +++++- .../java/no/beint/thim/example/GeneratedRendererTest.java | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt index b2bc103..1734f31 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt @@ -56,7 +56,11 @@ internal class RendererGenerator( fun compile(templateName: String, model: KSClassDeclaration, nodes: List): CompiledTemplate { val modelName = model.qualifiedName?.asString() ?: error("$templateName: model must have a qualified name") - val rendererName = modelName.replace(Regex("[^A-Za-z0-9_]"), "_") + "ThimRenderer" + // The suffix differs from the single-file layout's "ThimRenderer" on purpose: Gradle's + // incremental Java compilation keeps the registry file's stale class list across the + // layout change, and reusing those class names made it delete every renderer class + // while recompiling only the edited one. + val rendererName = modelName.replace(Regex("[^A-Za-z0-9_]"), "_") + "Renderer" val staticContent = StaticContent() val code = CodeWriter(staticContent) val locales = if (usesMessages(nodes)) { diff --git a/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java b/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java index 96091b1..f089c7a 100644 --- a/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java +++ b/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java @@ -74,7 +74,7 @@ private static String encoded(String value) { @Test void partitionsLargeRenderersBelowTheHotSpotHugeMethodThreshold() throws IOException { - var resource = "/no/beint/thim/example/generated/no_beint_thim_example_page_LargePageThimRenderer.class"; + var resource = "/no/beint/thim/example/generated/no_beint_thim_example_page_LargePageRenderer.class"; byte[] bytes; try (var input = getClass().getResourceAsStream(resource)) { bytes = input.readAllBytes(); From a4ab296748b328edc559dbe6b5ccf6503c15351c Mon Sep 17 00:00:00 2001 From: GT Date: Fri, 18 Sep 2026 17:22:55 +0200 Subject: [PATCH 3/7] Document per-template generated sources and their measured edit loop Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 11 +++++++++++ DESIGN.md | 2 +- PERFORMANCE_AUDIT.md | 30 +++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9229492..a5c82e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +- Generate one Java source file and one static resource per template instead of a single + registry source with every renderer. Renderers no longer reference the registry, so an + unchanged template regenerates byte-identical files and Gradle's incremental Java + compilation recompiles only the edited renderer and the registry. In ReAI a one-template + edit recompiles 2 classes in about 0.4 s instead of the whole 14.8 MB source in 6.3 s, and a + full `compileJava` takes 2.2 s. Generated renderer classes now end in `Renderer` rather than + `ThimRenderer`; they are package-private and not part of the public API. Static content is + no longer deduplicated across templates, so ReAI's resources grow from 1.6 MB to 6.5 MB. + ## 0.11.2 - Dispatch generated template registries through a class index instead of linear `==` and diff --git a/DESIGN.md b/DESIGN.md index 557cc0a..00aaf92 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -14,7 +14,7 @@ Thim optimizes for four properties: 1. The Gradle plugin tracks HTML, strict YAML message catalogs and model sources. 2. KSP resolves a page-model class from the template filename and configured model packages. 3. The compiler links fixed layouts and fragments, then validates properties, nullability, locale/key/argument parity, plural and select rules, and supported directives. -4. It emits readable Java renderers and one package-local resource containing static UTF-8 content. +4. It emits one readable Java renderer and one package-local resource of static UTF-8 content per template, plus a registry that maps page-model classes to renderers. A renderer depends only on its own resource, so an unchanged template regenerates byte-identical files and Gradle's incremental Java compilation skips it. 5. Each template jar publishes its generated registry through Java's service loader. Kotlin modules use the KSP Gradle integration. Java modules run KSP2 directly against Java sources, including records and bean accessors. Both paths call the same compiler and generate the same runtime code. diff --git a/PERFORMANCE_AUDIT.md b/PERFORMANCE_AUDIT.md index 7ec28e2..d3819d6 100644 --- a/PERFORMANCE_AUDIT.md +++ b/PERFORMANCE_AUDIT.md @@ -98,7 +98,7 @@ and allocations are recorded in [the benchmark results](benchmark/results/2026-0 | Priority | Finding | Next step | | --- | --- | --- | -| Highest follow-up | Generated renderers share one Java source and static resource, with aggregating KSP dependencies. ReAI's existing local output contains 314 renderer classes in a 13.49 MB source file, plus a 1.51 MB static resource. | Profile a one-model edit and a one-template edit. Consider per-template sources/resources with isolating dependencies, while retaining a separate aggregating registry. Shared layouts, message changes, additions, and deletions need explicit invalidation coverage. | +| Highest follow-up | Generated renderers share one Java source and static resource, with aggregating KSP dependencies. ReAI's existing local output contains 314 renderer classes in a 13.49 MB source file, plus a 1.51 MB static resource. | Implemented as per-template sources and resources after 0.11.2; see the per-template section below. | | Medium | Each expression property lookup can walk KSP properties and supertypes again, including properties from shared layouts. | Profile symbol-resolution time, then consider a cache confined to one processor invocation. Keep missing-property diagnostics and generic/inherited property behavior intact. | | Medium | Generated `supports`, `supportsReturnType`, request-data checks, and render dispatch use linear checks. | Add a benchmark with hundreds of page models and the full Spring handler path before replacing dispatch with a map or `ClassValue`. Preserve custom `TemplateSet` behavior and subclass handling. | | Medium | Servlet rendering allocates an 8 KiB body buffer and a 1 KiB output buffer, then buffers the whole response. | Measure full pages and small HTMX responses through the Spring adapter. Tune sizing only with allocation and latency evidence. Streaming changes failure handling and content-length behavior, so it needs separate design work. | @@ -342,3 +342,31 @@ long plain-ASCII runs and loses on the text these applications render: The current per-character loop runs at about 0.6 ns per character, close to the copy floor, and stays. + +## Per-template generated sources — 18 September 2026 + +After 0.11.2 the compiler emits one Java source and one static resource per template, and +the registry is the only file that references every renderer. All KSP outputs keep +aggregating dependencies, so KSP still regenerates everything on each run; the gain comes +from Gradle's incremental Java compilation seeing byte-identical files for unchanged +templates. Measured on ReAI (344 renderers), build cache disabled, `--info`: + +| Step | 0.11.2 single source | Per-template sources | +| --- | ---: | ---: | +| Full `:web-app:compileJava` | 6.3 s | 2.2 s | +| One-template edit, `:web-app:compileJava` | 6.3 s, whole file | 0.4–0.6 s, "Incremental compilation of 2 classes" | +| Generated files changed by that edit | 1 of 1 | 2 of 690 (the renderer and its resource) | +| Forced `:web-app:kspKotlin` | 8.0–11.6 s | 5.5–7.9 s (within daemon noise) | +| Static resources | 1.6 MB, deduplicated | 6.5 MB, one per template | + +The first attempt kept the `ThimRenderer` class suffix. Gradle's previous-compilation data +still listed every renderer class under `ThimTemplates.java` after the layout change, so the +next one-template edit deleted all 345 class files while passing two sources to javac, and +retrying did not recover. Renaming the generated classes to `…Renderer` sidesteps the stale +list: the transition build, the first edit, a retry and a second edit all succeed, and the +"346 classes" Gradle reports for an edit are two real compilations plus stale names it +deletes as no-ops in about 0.2 s. A `clean` build reports 2 classes. + +Not changed: KSP still parses all templates and resolves every model on each run, Kotlin +compilation is unaffected, and the Java-only `ThimCompile` path writes the same per-template +files into its own output directory. From b9b67c809e6d8790d0b7db104f49a7d90cdc2952 Mon Sep 17 00:00:00 2001 From: GT Date: Fri, 18 Sep 2026 17:26:12 +0200 Subject: [PATCH 4/7] Record the full edit-loop timings for per-template sources Co-Authored-By: Claude Fable 5.1 --- PERFORMANCE_AUDIT.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/PERFORMANCE_AUDIT.md b/PERFORMANCE_AUDIT.md index d3819d6..d5a78ea 100644 --- a/PERFORMANCE_AUDIT.md +++ b/PERFORMANCE_AUDIT.md @@ -359,6 +359,19 @@ templates. Measured on ReAI (344 renderers), build cache disabled, `--info`: | Forced `:web-app:kspKotlin` | 8.0–11.6 s | 5.5–7.9 s (within daemon noise) | | Static resources | 1.6 MB, deduplicated | 6.5 MB, one per template | +The whole edit loop (`:web-app:classes` after touching one template, warm daemon, build +cache disabled, three repetitions each): + +| Task | 0.11.2 single source | Per-template sources | +| --- | ---: | ---: | +| `:web-app:kspKotlin` | 3.0–4.0 s | 4.5–4.9 s | +| `:web-app:compileKotlin` | 1.0–1.2 s | 0.06–0.09 s | +| `:web-app:compileJava` | 6.1–6.3 s | 0.26–0.28 s | +| Total build | 10.4–12.1 s | 5.2–5.6 s | + +Kotlin's incremental compilation benefits as well, because only one Java source on its +source path changes. KSP is now the dominant cost of a template edit. + The first attempt kept the `ThimRenderer` class suffix. Gradle's previous-compilation data still listed every renderer class under `ThimTemplates.java` after the layout change, so the next one-template edit deleted all 345 class files while passing two sources to javac, and From c372593fef38afaedb9f4ed6cfcd9042a0e7ac1c Mon Sep 17 00:00:00 2001 From: GT Date: Fri, 18 Sep 2026 18:00:54 +0200 Subject: [PATCH 5/7] Group renderers into a fixed number of hash-assigned source files Per-template files made KSP about 1.1 s slower in ReAI because KSP registers every generated Java file; Thim's own generation only grew by 0.2 s. Renderers are now grouped into 32 files by a stable hash of the page-model name, each with its own static resource, so an edit recompiles one file of roughly ten renderers and KSP sees 32 files. Golden renders recorded with the 0.11.2 compiler prove the output is byte-identical. Co-Authored-By: Claude Fable 5.1 --- .../beint/thim/compiler/RendererGenerator.kt | 30 +- .../no/beint/thim/compiler/ThimProcessor.kt | 46 +- example/build.gradle.kts | 1 + .../beint/thim/example/GoldenRenderTest.java | 64 +++ .../src/test/resources/golden/derived.de.html | 5 + .../src/test/resources/golden/derived.en.html | 5 + .../src/test/resources/golden/derived.nb.html | 5 + .../resources/golden/home-with-errors.de.html | 56 +++ .../resources/golden/home-with-errors.en.html | 56 +++ .../resources/golden/home-with-errors.nb.html | 56 +++ .../src/test/resources/golden/home.de.html | 56 +++ .../src/test/resources/golden/home.en.html | 56 +++ .../src/test/resources/golden/home.nb.html | 56 +++ .../src/test/resources/golden/large.de.html | 408 ++++++++++++++++++ .../src/test/resources/golden/large.en.html | 408 ++++++++++++++++++ .../src/test/resources/golden/large.nb.html | 408 ++++++++++++++++++ 16 files changed, 1692 insertions(+), 24 deletions(-) create mode 100644 example/src/test/java/no/beint/thim/example/GoldenRenderTest.java create mode 100644 example/src/test/resources/golden/derived.de.html create mode 100644 example/src/test/resources/golden/derived.en.html create mode 100644 example/src/test/resources/golden/derived.nb.html create mode 100644 example/src/test/resources/golden/home-with-errors.de.html create mode 100644 example/src/test/resources/golden/home-with-errors.en.html create mode 100644 example/src/test/resources/golden/home-with-errors.nb.html create mode 100644 example/src/test/resources/golden/home.de.html create mode 100644 example/src/test/resources/golden/home.en.html create mode 100644 example/src/test/resources/golden/home.nb.html create mode 100644 example/src/test/resources/golden/large.de.html create mode 100644 example/src/test/resources/golden/large.en.html create mode 100644 example/src/test/resources/golden/large.nb.html diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt index 1734f31..1b2d37f 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt @@ -10,16 +10,13 @@ import java.io.ByteArrayOutputStream import java.nio.charset.StandardCharsets /** - * One renderer class and its own static UTF-8 content. Keeping each template's output - * independent of every other template makes the generated file for an unchanged - * template byte-identical between builds, so Gradle's incremental Java compilation - * recompiles only the renderer whose template changed. + * One renderer class. Its static UTF-8 content lives in the shared [StaticContent] of the + * generated source file it is grouped into, referenced through that file's holder class. */ internal data class CompiledTemplate( val model: KSClassDeclaration, val rendererName: String, val source: String, - val staticContent: ByteArray, val usesRequestDataValues: Boolean, ) @@ -54,15 +51,25 @@ internal class RendererGenerator( fun usedRootProperties(model: KSClassDeclaration): Set = usedRootProperties[model.qualifiedName?.asString()] ?: emptySet() - fun compile(templateName: String, model: KSClassDeclaration, nodes: List): CompiledTemplate { + /** + * [staticContent] and [holderName] belong to the generated source file this renderer is + * grouped into: static runs are appended to that file's shared content and referenced as + * `holderName.STATIC`. + */ + fun compile( + templateName: String, + model: KSClassDeclaration, + nodes: List, + staticContent: StaticContent, + holderName: String, + ): CompiledTemplate { val modelName = model.qualifiedName?.asString() ?: error("$templateName: model must have a qualified name") // The suffix differs from the single-file layout's "ThimRenderer" on purpose: Gradle's // incremental Java compilation keeps the registry file's stale class list across the // layout change, and reusing those class names made it delete every renderer class // while recompiling only the edited one. val rendererName = modelName.replace(Regex("[^A-Za-z0-9_]"), "_") + "Renderer" - val staticContent = StaticContent() - val code = CodeWriter(staticContent) + val code = CodeWriter(staticContent, holderName) val locales = if (usesMessages(nodes)) { catalog.supportedLocales.filterTo(linkedSetOf()) { it != catalog.defaultLocale } } else { @@ -85,8 +92,6 @@ internal class RendererGenerator( code.line("final class $rendererName {") code.indent { - code.line("static final byte[] STATIC = HtmlOutput.resource($rendererName.class, \"$rendererName.bin\");") - code.line() code.line("private $rendererName() {}") code.line() code.line("static void render($modelName model, RenderContext context, HtmlOutput output) throws IOException {") @@ -137,7 +142,7 @@ internal class RendererGenerator( } code.line("}") val source = code.toString() - return CompiledTemplate(model, rendererName, source, staticContent.bytes(), "context.requestDataValues()" in source) + return CompiledTemplate(model, rendererName, source, "context.requestDataValues()" in source) } private fun renderNodes(nodes: List, scope: Scope, code: CodeWriter, context: String) { @@ -1434,6 +1439,7 @@ internal class RendererGenerator( private class CodeWriter( private val staticContent: StaticContent, + private val holderName: String, ) { private val output = StringBuilder() private val pending = StringBuilder() @@ -1475,7 +1481,7 @@ internal class RendererGenerator( if (pending.isEmpty()) return val range = staticContent.append(pending.toString()) output.append(" ".repeat(depth)) - .append("output.raw(STATIC, ") + .append("output.raw($holderName.STATIC, ") .append(range.first) .append(", ") .append(range.last - range.first + 1) diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt index d7c6c4a..99f962d 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt @@ -111,8 +111,10 @@ private class ThimProcessor( RouteCatalog(emptyList(), emptyList(), extractedRoutes.files) } val generator = RendererGenerator(catalog, routeCatalog, strictModels) + val statics = List(RENDERER_FILES) { StaticContent() } val compiled = templates.map { template -> - generator.compile(template.name, template.model, template.nodes) + val file = rendererFile(template.model) + generator.compile(template.name, template.model, template.nodes, statics[file], holderName(file)) } problems += generator.errors collect(problems) { if (strictModels) reportUnusedProperties(templates, generator) } @@ -125,7 +127,7 @@ private class ThimProcessor( completed = true return emptyList() } - generate(compiled, extractedRoutes) + generate(compiled, statics, extractedRoutes) if (catalog.definitions().isNotEmpty()) { val files = (compiled.mapNotNull { it.model.containingFile } + extractedRoutes.files).distinct().toTypedArray() generateMessages(catalog, files) @@ -188,25 +190,29 @@ private class ThimProcessor( } /** - * Each template gets its own source file and static resource. A renderer references - * only its own content, never the registry or another renderer, so an unchanged - * template regenerates byte-identical files and Gradle's incremental Java compilation - * skips it. The registry is the only file that references every renderer. + * Renderers are grouped into a fixed number of source files by a stable hash of the + * page-model name, each with its own static resource. A renderer references only its + * file's holder class, never the registry or another file, so an unchanged file + * regenerates byte-identical output and Gradle's incremental Java compilation recompiles + * only the file whose template changed plus the registry. A fixed count keeps KSP's + * per-file overhead small; membership depends on the model name alone, so adding or + * removing a template touches one file. */ - private fun generate(compiled: List, routeCatalog: RouteCatalog) { + private fun generate(compiled: List, statics: List, routeCatalog: RouteCatalog) { val files = (compiled.mapNotNull { it.model.containingFile } + routeCatalog.files).distinct().toTypedArray() val dependencies = Dependencies(aggregating = true, *files) - compiled.forEach { template -> + compiled.groupBy { rendererFile(it.model) }.toSortedMap().forEach { (file, members) -> + val holder = holderName(file) codeGenerator.createNewFile( dependencies = dependencies, packageName = generatedPackage, - fileName = template.rendererName, + fileName = holder, extensionName = "bin", - ).use { it.write(template.staticContent) } + ).use { it.write(statics[file].bytes()) } codeGenerator.createNewFile( dependencies = dependencies, packageName = generatedPackage, - fileName = template.rendererName, + fileName = holder, extensionName = "java", ).bufferedWriter(StandardCharsets.UTF_8).use { output -> output.appendLine("package $generatedPackage;") @@ -215,7 +221,15 @@ private class ThimProcessor( output.appendLine("import no.beint.thim.HtmlOutput;") output.appendLine("import no.beint.thim.RenderContext;") output.appendLine() - output.append(template.source) + output.appendLine("final class $holder {") + output.appendLine(" static final byte[] STATIC = HtmlOutput.resource($holder.class, \"$holder.bin\");") + output.appendLine() + output.appendLine(" private $holder() {}") + output.appendLine("}") + members.sortedBy { it.rendererName }.forEach { template -> + output.appendLine() + output.append(template.source) + } } } codeGenerator.createNewFile( @@ -402,7 +416,15 @@ private class ThimProcessor( private fun requiredPath(environment: SymbolProcessorEnvironment, key: String): Path = Path.of(requireNotNull(environment.options[key]) { "Missing KSP option '$key'" }).toAbsolutePath().normalize() + private fun rendererFile(model: KSClassDeclaration): Int = + Math.floorMod(model.qualifiedName!!.asString().hashCode(), RENDERER_FILES) + + private fun holderName(file: Int): String = "${registryName}Part$file" + private companion object { + /** Generated renderer source files per module; see [generate]. */ + const val RENDERER_FILES = 32 + fun conventionalModelName(templateName: String): String = templateName .split(Regex("[^A-Za-z0-9]+")) .filter(String::isNotEmpty) diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 26ead6b..6c98b48 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { tasks.test { useJUnitPlatform() + systemProperty("thim.golden.record", System.getProperty("thim.golden.record", "false")) } ksp { diff --git a/example/src/test/java/no/beint/thim/example/GoldenRenderTest.java b/example/src/test/java/no/beint/thim/example/GoldenRenderTest.java new file mode 100644 index 0000000..3115d1b --- /dev/null +++ b/example/src/test/java/no/beint/thim/example/GoldenRenderTest.java @@ -0,0 +1,64 @@ +package no.beint.thim.example; + +import no.beint.thim.HtmlOutput; +import no.beint.thim.RenderContext; +import no.beint.thim.example.generated.ExampleTemplates; +import no.beint.thim.example.page.DerivedPage; +import no.beint.thim.example.page.LargePage; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Rendered HTML for every example page must stay byte-identical across compiler changes + * that only reorganize generated code. The goldens were recorded with the 0.11.2 compiler; + * re-record deliberately with {@code -Dthim.golden.record=true} when output is meant to change. + */ +class GoldenRenderTest { + private static final Path GOLDEN_DIRECTORY = Path.of("src/test/resources/golden"); + + @Test + void renderedPagesMatchTheRecordedGoldens() throws IOException { + var pages = new LinkedHashMap(); + pages.put("home", new HomeCtrl().home()); + pages.put("home-with-errors", new HomeCtrl().feedback(new FeedbackForm("", "Message & ", 4, true, "high", "bug"))); + pages.put("large", new LargePage("Large & ")); + pages.put("derived", new DerivedPage("source")); + var record = Boolean.getBoolean("thim.golden.record"); + var rendered = new LinkedHashMap(); + for (var page : pages.entrySet()) { + for (var locale : new Locale[] {Locale.ENGLISH, Locale.forLanguageTag("nb"), Locale.GERMAN}) { + rendered.put(page.getKey() + "." + locale.toLanguageTag() + ".html", render(page.getValue(), locale)); + } + } + if (record) { + Files.createDirectories(GOLDEN_DIRECTORY); + for (var entry : rendered.entrySet()) { + Files.writeString(GOLDEN_DIRECTORY.resolve(entry.getKey()), entry.getValue(), StandardCharsets.UTF_8); + } + } + for (Map.Entry entry : rendered.entrySet()) { + var golden = GOLDEN_DIRECTORY.resolve(entry.getKey()); + assertTrue(Files.isRegularFile(golden), "missing golden " + golden + "; record with -Dthim.golden.record=true"); + assertEquals(Files.readString(golden, StandardCharsets.UTF_8), entry.getValue(), entry.getKey()); + } + } + + private static String render(Object page, Locale locale) throws IOException { + var bytes = new ByteArrayOutputStream(); + var output = new HtmlOutput(bytes, 64); + new ExampleTemplates().render(page, new RenderContext(locale, "/app"), output); + output.flush(); + return bytes.toString(StandardCharsets.UTF_8); + } +} diff --git a/example/src/test/resources/golden/derived.de.html b/example/src/test/resources/golden/derived.de.html new file mode 100644 index 0000000..235d074 --- /dev/null +++ b/example/src/test/resources/golden/derived.de.html @@ -0,0 +1,5 @@ + + +Derived model +SOURCE + diff --git a/example/src/test/resources/golden/derived.en.html b/example/src/test/resources/golden/derived.en.html new file mode 100644 index 0000000..235d074 --- /dev/null +++ b/example/src/test/resources/golden/derived.en.html @@ -0,0 +1,5 @@ + + +Derived model +SOURCE + diff --git a/example/src/test/resources/golden/derived.nb.html b/example/src/test/resources/golden/derived.nb.html new file mode 100644 index 0000000..235d074 --- /dev/null +++ b/example/src/test/resources/golden/derived.nb.html @@ -0,0 +1,5 @@ + + +Derived model +SOURCE + diff --git a/example/src/test/resources/golden/home-with-errors.de.html b/example/src/test/resources/golden/home-with-errors.de.html new file mode 100644 index 0000000..a1cb9d0 --- /dev/null +++ b/example/src/test/resources/golden/home-with-errors.de.html @@ -0,0 +1,56 @@ + + + + + + Thim 0.11.0 + + +
+

Thim 0.11.0

+

Typed models, compiled HTML, no runtime engine.

+

3 unread messages

+
    +
  • + + Safe + Properties and messages are checked while the application compiles. + + Details +
  • + + Small + The runtime is dependency-free Java. + + Details +
  • + + Fast + Generated code writes directly to the HTTP response. + + Details +
  • +
+

+ Health check + +

+
+ + + Author is required + + + + + + + +
+
+ + diff --git a/example/src/test/resources/golden/home-with-errors.en.html b/example/src/test/resources/golden/home-with-errors.en.html new file mode 100644 index 0000000..078627e --- /dev/null +++ b/example/src/test/resources/golden/home-with-errors.en.html @@ -0,0 +1,56 @@ + + + + + + Thim 0.11.0 + + +
+

Thim 0.11.0

+

Typed models, compiled HTML, no runtime engine.

+

3 unread messages

+
    +
  • + + Safe + Properties and messages are checked while the application compiles. + + Details +
  • + + Small + The runtime is dependency-free Java. + + Details +
  • + + Fast + Generated code writes directly to the HTTP response. + + Details +
  • +
+

+ Health check + +

+
+ + + Author is required + + + + + + + +
+
+ + diff --git a/example/src/test/resources/golden/home-with-errors.nb.html b/example/src/test/resources/golden/home-with-errors.nb.html new file mode 100644 index 0000000..f1e91b1 --- /dev/null +++ b/example/src/test/resources/golden/home-with-errors.nb.html @@ -0,0 +1,56 @@ + + + + + + Thim 0.11.0 + + +
+

Thim 0.11.0

+

Typed models, compiled HTML, no runtime engine.

+

3 uleste meldinger

+
    +
  • + + Safe + Properties and messages are checked while the application compiles. + + Detaljer +
  • + + Small + The runtime is dependency-free Java. + + Detaljer +
  • + + Fast + Generated code writes directly to the HTTP response. + + Detaljer +
  • +
+

+ Helsesjekk + +

+
+ + + Author is required + + + + + + + +
+
+ + diff --git a/example/src/test/resources/golden/home.de.html b/example/src/test/resources/golden/home.de.html new file mode 100644 index 0000000..0aa8d17 --- /dev/null +++ b/example/src/test/resources/golden/home.de.html @@ -0,0 +1,56 @@ + + + + + + Thim 0.11.0 + + +
+

Thim 0.11.0

+

Typed models, compiled HTML, no runtime engine.

+

3 unread messages

+
    +
  • + + Safe + Properties and messages are checked while the application compiles. + + Details +
  • + + Small + The runtime is dependency-free Java. + + Details +
  • + + Fast + Generated code writes directly to the HTTP response. + + Details +
  • +
+

+ Health check + +

+
+ + + + + + + + + + +
+
+ + diff --git a/example/src/test/resources/golden/home.en.html b/example/src/test/resources/golden/home.en.html new file mode 100644 index 0000000..9543141 --- /dev/null +++ b/example/src/test/resources/golden/home.en.html @@ -0,0 +1,56 @@ + + + + + + Thim 0.11.0 + + +
+

Thim 0.11.0

+

Typed models, compiled HTML, no runtime engine.

+

3 unread messages

+
    +
  • + + Safe + Properties and messages are checked while the application compiles. + + Details +
  • + + Small + The runtime is dependency-free Java. + + Details +
  • + + Fast + Generated code writes directly to the HTTP response. + + Details +
  • +
+

+ Health check + +

+
+ + + + + + + + + + +
+
+ + diff --git a/example/src/test/resources/golden/home.nb.html b/example/src/test/resources/golden/home.nb.html new file mode 100644 index 0000000..092f23f --- /dev/null +++ b/example/src/test/resources/golden/home.nb.html @@ -0,0 +1,56 @@ + + + + + + Thim 0.11.0 + + +
+

Thim 0.11.0

+

Typed models, compiled HTML, no runtime engine.

+

3 uleste meldinger

+
    +
  • + + Safe + Properties and messages are checked while the application compiles. + + Detaljer +
  • + + Small + The runtime is dependency-free Java. + + Detaljer +
  • + + Fast + Generated code writes directly to the HTTP response. + + Detaljer +
  • +
+

+ Helsesjekk + +

+
+ + + + + + + + + + +
+
+ + diff --git a/example/src/test/resources/golden/large.de.html b/example/src/test/resources/golden/large.de.html new file mode 100644 index 0000000..eb211a7 --- /dev/null +++ b/example/src/test/resources/golden/large.de.html @@ -0,0 +1,408 @@ + + +Large renderer regression fixture + +
+Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +
+ + diff --git a/example/src/test/resources/golden/large.en.html b/example/src/test/resources/golden/large.en.html new file mode 100644 index 0000000..eb211a7 --- /dev/null +++ b/example/src/test/resources/golden/large.en.html @@ -0,0 +1,408 @@ + + +Large renderer regression fixture + +
+Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +
+ + diff --git a/example/src/test/resources/golden/large.nb.html b/example/src/test/resources/golden/large.nb.html new file mode 100644 index 0000000..eb211a7 --- /dev/null +++ b/example/src/test/resources/golden/large.nb.html @@ -0,0 +1,408 @@ + + +Large renderer regression fixture + +
+Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +Large & <value> +
+ + From 71b955fe4a7321d498499a255626bce4e8597f7c Mon Sep 17 00:00:00 2001 From: GT Date: Fri, 18 Sep 2026 18:02:18 +0200 Subject: [PATCH 6/7] Nest renderers in their source file's holder class javac's auxiliary-class lint, fatal under -Werror in one ReAI module, rejects package-private top-level classes referenced from another source file. Co-Authored-By: Claude Fable 5.1 --- .../no/beint/thim/compiler/RendererGenerator.kt | 4 +++- .../kotlin/no/beint/thim/compiler/ThimProcessor.kt | 11 +++++++---- .../no/beint/thim/example/GeneratedRendererTest.java | 5 ++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt index 1b2d37f..6ca544d 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt @@ -90,7 +90,9 @@ internal class RendererGenerator( generatedHelper = 0 pendingHelpers.clear() - code.line("final class $rendererName {") + // Nested in its file's holder class: javac's auxiliary-class lint (fatal under -Werror) + // rejects package-private top-level classes referenced from another source file. + code.line("static final class $rendererName {") code.indent { code.line("private $rendererName() {}") code.line() diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt index 99f962d..2573465 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt @@ -225,11 +225,11 @@ private class ThimProcessor( output.appendLine(" static final byte[] STATIC = HtmlOutput.resource($holder.class, \"$holder.bin\");") output.appendLine() output.appendLine(" private $holder() {}") - output.appendLine("}") members.sortedBy { it.rendererName }.forEach { template -> output.appendLine() - output.append(template.source) + output.append(template.source.prependIndent(" ").replace(Regex("(?m)^ +$"), "")) } + output.appendLine("}") } } codeGenerator.createNewFile( @@ -296,7 +296,7 @@ private class ThimProcessor( output.appendLine(" switch (index) {") compiled.forEachIndexed { index, template -> val modelName = template.model.qualifiedName!!.asString() - output.appendLine(" case $index -> ${template.rendererName}.render(($modelName) model, context, output);") + output.appendLine(" case $index -> ${rendererReference(template)}.render(($modelName) model, context, output);") } output.appendLine(" default -> throw new IllegalStateException(\"Unknown template index \" + index);") output.appendLine(" }") @@ -305,7 +305,7 @@ private class ThimProcessor( compiled.forEach { val modelName = it.model.qualifiedName!!.asString() output.appendLine(" if (model instanceof $modelName typed) {") - output.appendLine(" ${it.rendererName}.render(typed, context, output);") + output.appendLine(" ${rendererReference(it)}.render(typed, context, output);") output.appendLine(" return;") output.appendLine(" }") } @@ -421,6 +421,9 @@ private class ThimProcessor( private fun holderName(file: Int): String = "${registryName}Part$file" + private fun rendererReference(template: CompiledTemplate): String = + "${holderName(rendererFile(template.model))}.${template.rendererName}" + private companion object { /** Generated renderer source files per module; see [generate]. */ const val RENDERER_FILES = 32 diff --git a/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java b/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java index f089c7a..0498358 100644 --- a/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java +++ b/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java @@ -74,7 +74,10 @@ private static String encoded(String value) { @Test void partitionsLargeRenderersBelowTheHotSpotHugeMethodThreshold() throws IOException { - var resource = "/no/beint/thim/example/generated/no_beint_thim_example_page_LargePageRenderer.class"; + // Renderers nest in a holder chosen by the same stable hash the compiler uses. + var file = Math.floorMod(LargePage.class.getName().hashCode(), 32); + var resource = "/no/beint/thim/example/generated/ExampleTemplatesPart" + file + + "$no_beint_thim_example_page_LargePageRenderer.class"; byte[] bytes; try (var input = getClass().getResourceAsStream(resource)) { bytes = input.readAllBytes(); From 5d3499a11d7f2dcb42d03c9c6bb2bbf0809743eb Mon Sep 17 00:00:00 2001 From: GT Date: Fri, 18 Sep 2026 18:04:34 +0200 Subject: [PATCH 7/7] Document the grouped generated sources and their measurements Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 19 ++++++----- DESIGN.md | 2 +- PERFORMANCE_AUDIT.md | 75 ++++++++++++++++++++++++-------------------- 3 files changed, 53 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c82e9..b9d5b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,17 @@ ## Unreleased -- Generate one Java source file and one static resource per template instead of a single - registry source with every renderer. Renderers no longer reference the registry, so an - unchanged template regenerates byte-identical files and Gradle's incremental Java - compilation recompiles only the edited renderer and the registry. In ReAI a one-template - edit recompiles 2 classes in about 0.4 s instead of the whole 14.8 MB source in 6.3 s, and a - full `compileJava` takes 2.2 s. Generated renderer classes now end in `Renderer` rather than - `ThimRenderer`; they are package-private and not part of the public API. Static content is - no longer deduplicated across templates, so ReAI's resources grow from 1.6 MB to 6.5 MB. +- Generate renderers into a fixed set of 32 source files chosen by a stable hash of the + page-model name, each with its own static resource, instead of one source file holding + every renderer. Renderers no longer reference the registry, so an unchanged file + regenerates byte-identical output and Gradle's incremental Java compilation recompiles + only the file whose template changed plus the registry. In ReAI a one-template edit now + takes 4.3 s end to end instead of 10.4–12.1 s (`compileJava` 6.2 s → 0.3 s, `compileKotlin` + 1.1 s → 0.09 s), and a full `compileJava` takes 2.8 s. Rendered HTML is byte-identical to + 0.11.2, checked by recorded golden renders. Generated renderer classes are now nested in + `Part` holders and end in `Renderer`; they are package-private, not API. + Static content is deduplicated per file rather than per module, so ReAI's resources grow + from 1.6 MB to 2.8 MB. ## 0.11.2 diff --git a/DESIGN.md b/DESIGN.md index 00aaf92..7b6315a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -14,7 +14,7 @@ Thim optimizes for four properties: 1. The Gradle plugin tracks HTML, strict YAML message catalogs and model sources. 2. KSP resolves a page-model class from the template filename and configured model packages. 3. The compiler links fixed layouts and fragments, then validates properties, nullability, locale/key/argument parity, plural and select rules, and supported directives. -4. It emits one readable Java renderer and one package-local resource of static UTF-8 content per template, plus a registry that maps page-model classes to renderers. A renderer depends only on its own resource, so an unchanged template regenerates byte-identical files and Gradle's incremental Java compilation skips it. +4. It emits readable Java renderers grouped into a fixed set of source files by a stable hash of the page-model name, each file with its own package-local resource of static UTF-8 content, plus a registry that maps page-model classes to renderers. A renderer depends only on its file's resource, so an unchanged file regenerates byte-identical output and Gradle's incremental Java compilation skips it. 5. Each template jar publishes its generated registry through Java's service loader. Kotlin modules use the KSP Gradle integration. Java modules run KSP2 directly against Java sources, including records and bean accessors. Both paths call the same compiler and generate the same runtime code. diff --git a/PERFORMANCE_AUDIT.md b/PERFORMANCE_AUDIT.md index d5a78ea..93dee44 100644 --- a/PERFORMANCE_AUDIT.md +++ b/PERFORMANCE_AUDIT.md @@ -343,43 +343,50 @@ long plain-ASCII runs and loses on the text these applications render: The current per-character loop runs at about 0.6 ns per character, close to the copy floor, and stays. -## Per-template generated sources — 18 September 2026 +## Grouped generated sources — 18 September 2026 -After 0.11.2 the compiler emits one Java source and one static resource per template, and -the registry is the only file that references every renderer. All KSP outputs keep -aggregating dependencies, so KSP still regenerates everything on each run; the gain comes -from Gradle's incremental Java compilation seeing byte-identical files for unchanged -templates. Measured on ReAI (344 renderers), build cache disabled, `--info`: +After 0.11.2 the compiler emits renderers into 32 source files chosen by +`floorMod(modelName.hashCode(), 32)`, each with a holder class that owns the file's static +resource; renderers are nested in that holder and reference only its `STATIC`. The registry +is the only file that references every renderer. KSP dependencies stay aggregating, so KSP +regenerates everything on each run; the gain comes from Gradle's incremental Java +compilation seeing byte-identical files for unchanged templates, and from Kotlin's +incremental compilation seeing one changed Java source instead of one huge one. -| Step | 0.11.2 single source | Per-template sources | -| --- | ---: | ---: | -| Full `:web-app:compileJava` | 6.3 s | 2.2 s | -| One-template edit, `:web-app:compileJava` | 6.3 s, whole file | 0.4–0.6 s, "Incremental compilation of 2 classes" | -| Generated files changed by that edit | 1 of 1 | 2 of 690 (the renderer and its resource) | -| Forced `:web-app:kspKotlin` | 8.0–11.6 s | 5.5–7.9 s (within daemon noise) | -| Static resources | 1.6 MB, deduplicated | 6.5 MB, one per template | +Two intermediate layouts were measured and rejected on ReAI (344 renderers): + +| Layout | Forced `kspKotlin` | Edit `compileJava` | Resources | Note | +| --- | ---: | ---: | ---: | --- | +| 0.11.2, one source | 2.8–3.0 s | 6.1–6.3 s | 1.6 MB | baseline | +| one file per template | 4.4–4.7 s | 0.26–0.28 s | 6.5 MB | KSP registers 345 generated Java files: Thim's own generation grew only 0.07 → 0.30 s, the rest is KSP; no static dedup | +| 32 hash-assigned files | 3.6 s | 0.29–0.31 s | 2.8 MB | shipped | The whole edit loop (`:web-app:classes` after touching one template, warm daemon, build -cache disabled, three repetitions each): +cache disabled, two or three repetitions): -| Task | 0.11.2 single source | Per-template sources | +| Task | 0.11.2 | 32 files | | --- | ---: | ---: | -| `:web-app:kspKotlin` | 3.0–4.0 s | 4.5–4.9 s | -| `:web-app:compileKotlin` | 1.0–1.2 s | 0.06–0.09 s | -| `:web-app:compileJava` | 6.1–6.3 s | 0.26–0.28 s | -| Total build | 10.4–12.1 s | 5.2–5.6 s | - -Kotlin's incremental compilation benefits as well, because only one Java source on its -source path changes. KSP is now the dominant cost of a template edit. - -The first attempt kept the `ThimRenderer` class suffix. Gradle's previous-compilation data -still listed every renderer class under `ThimTemplates.java` after the layout change, so the -next one-template edit deleted all 345 class files while passing two sources to javac, and -retrying did not recover. Renaming the generated classes to `…Renderer` sidesteps the stale -list: the transition build, the first edit, a retry and a second edit all succeed, and the -"346 classes" Gradle reports for an edit are two real compilations plus stale names it -deletes as no-ops in about 0.2 s. A `clean` build reports 2 classes. - -Not changed: KSP still parses all templates and resolves every model on each run, Kotlin -compilation is unaffected, and the Java-only `ThimCompile` path writes the same per-template -files into its own output directory. +| `:web-app:kspKotlin` | 3.0–4.0 s | 3.6 s | +| `:web-app:compileKotlin` | 1.0–1.2 s | 0.09 s | +| `:web-app:compileJava` | 6.1–6.3 s | 0.3 s | +| Total build | 10.4–12.1 s | 4.3–4.4 s | + +Full `:web-app:compileJava` fell from 6.3 s to 2.8 s. Processor phases were timed with +temporary instrumentation: parsing, expansion, catalog, strict-model checks, routes and +compilation take 2.1–2.6 s in every layout, so the remaining KSP task time is KSP's own +handling of generated files. + +Two details matter for the upgrade. First, the renderer classes are named `…Renderer` +instead of `…ThimRenderer`: with the old names, Gradle's previous-compilation data still +listed every renderer under `ThimTemplates.java` after the layout change, so the next +one-template edit deleted all class files while passing two sources to javac, and retrying +did not recover. Second, renderers are nested in their holder rather than declared as +package-private top-level classes, because javac's auxiliary-class lint rejects those when +referenced from another file and one ReAI module compiles with `-Werror`. With both in +place, the sequence 0.11.2 full build → new layout (incremental) → edit → retry → second +edit → revert succeeds with the build cache disabled; Gradle's reported class counts for +an edit include stale old names it deletes as no-ops in about 0.25 s. + +Output equivalence is checked by `GoldenRenderTest` in the example module: twelve renders +(four pages, three locales, including form errors and a 64-byte output buffer) recorded +with the 0.11.2 compiler and compared byte for byte.