From 64588529b06e71d75c0be7217a6f65fad216cf71 Mon Sep 17 00:00:00 2001 From: GT Date: Wed, 23 Sep 2026 06:19:43 +0200 Subject: [PATCH] Keep the generated registry dispatch JIT-compilable and remove dead code The generated TemplateSet kept the index switch and the subclass instanceof chain in one render method. From about 215 templates that method exceeds HotSpot's 8000-byte huge-method limit and is never JIT-compiled; ReAI's web-app registry (353 templates, 13,137 bytes) paid about 190 ns per render instead of about 10 ns. Dispatch now goes through a small render method, 256-template chunk methods and a separate subclass fallback. Also removes the unused HtmlOutput.url, RouteCatalog.isEmpty, a test-only constructor and an unused import, compiles naming regexes once, and corrects stale versions and statuses in the docs. Releases as 0.13.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 13 ++ ISOLATED_PROJECTS.md | 19 ++- PERFORMANCE_AUDIT.md | 47 +++++++- README.md | 6 +- .../beint/thim/compiler/MessageGenerator.kt | 2 +- .../beint/thim/compiler/RegistryGenerator.kt | 111 ++++++++++++++++++ .../beint/thim/compiler/RendererGenerator.kt | 4 +- .../no/beint/thim/compiler/RouteCatalog.kt | 6 +- .../no/beint/thim/compiler/RouteGenerator.kt | 2 +- .../no/beint/thim/compiler/ThimProcessor.kt | 81 ++----------- .../thim/compiler/RegistryGeneratorTest.kt | 106 +++++++++++++++++ docs/index.html | 14 +-- .../java/no/beint/thim/gradle/ThimPlugin.java | 1 - .../main/java/no/beint/thim/HtmlOutput.java | 4 - settings.gradle.kts | 2 +- .../thim/spring/SpringRequestDataValues.java | 8 -- .../beint/thim/spring/ThimRendererTest.java | 2 +- 17 files changed, 306 insertions(+), 122 deletions(-) create mode 100644 compiler/src/main/kotlin/no/beint/thim/compiler/RegistryGenerator.kt create mode 100644 compiler/src/test/kotlin/no/beint/thim/compiler/RegistryGeneratorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a7eaf..d797cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.13.0 - 2026-09-23 + +- Split the generated registry's `render` dispatch into chunks of at most 256 templates and move the + subclass `instanceof` fallback and the `supportsReturnType` supertype chain into their own methods. + With about 215 or more templates, the single dispatch method exceeded HotSpot's 8000-byte limit for + JIT compilation and stayed interpreted: ReAI's web-app registry (353 templates, 13,137 bytes) paid + about 190 ns per render versus about 10 ns for the split form. Every dispatch method is now far + below the limit at any application size. Rendered output is unchanged. +- Remove `HtmlOutput.url(TrustedUrl)`. Generated renderers never called it, and calling it directly + skipped the context-path resolution that `TrustedUrl` values receive in templates. +- Compile identifier and route-joining regular expressions once instead of once per catalog key, + template or route. + ## 0.12.2 - 2026-09-19 - Avoid copying and reformatting generated renderer sources; write their final indentation directly. diff --git a/ISOLATED_PROJECTS.md b/ISOLATED_PROJECTS.md index 27dddcb..dfb841d 100644 --- a/ISOLATED_PROJECTS.md +++ b/ISOLATED_PROJECTS.md @@ -84,21 +84,16 @@ See [Gradle's migration guide](https://docs.gradle.org/9.7.1/userguide/isolated_ [Eteo #833](https://github.com/beint-no/eteo/pull/833), and [Ecomtools #380](https://github.com/beint-no/ecomtools/pull/380). -[Gradle #39057](https://github.com/gradle/gradle/pull/39057) proposes an isolated-projects regression test and API -contract clarification for `ResolvedComponentResult.moduleVersion` through the lazy resolution result. It covers -late producer versions, renamed archives, unrelated explicit capabilities and included-build identity. Both targeted -integration tests, CodeNarc and Checkstyle pass. Maintainer confirmation of that contract is still required. +[Gradle #39057](https://github.com/gradle/gradle/pull/39057), an isolated-projects regression test and API +contract clarification for `ResolvedComponentResult.moduleVersion`, was closed without merging on 2026-09-10. [The Spring Boot follow-up](https://github.com/spring-projects/spring-boot/issues/43755#issuecomment-5551490296) links this evidence and asks how the existing public custom-archive API should preserve compatibility when supplied only an artifact provider. The previously closed capability-based patch has not been resubmitted. -[KSP #3189](https://github.com/google/ksp/pull/3189) uses Gradle's effective `BuildFeatures` state, including CLI -precedence, while preserving explicit KSP opt-in and older Gradle support. Six focused tests pass, including actual -generated-source compilation and configuration-cache reuse. Plugin validation and formatting checks pass too. -Keep `ksp.project.isolation.enabled=true` until a release containing the fix is adopted. +[KSP #3189](https://github.com/google/ksp/pull/3189), which would have read Gradle's effective `BuildFeatures` +state, was closed without merging on 2026-09-05. Keep `ksp.project.isolation.enabled=true`. -Both contributions disclose AI assistance. The Gradle draft needs human review and DCO sign-off; the KSP draft needs -Google's contributor agreement. These are pending upstream contributions, not released fixes. Once accepted and -released, retest normal and isolated `bootJar`, custom layer coordinates, included builds, IDE import and cache reuse -before enabling isolation globally. No forked Spring Boot or KSP artifact is used by the consumers. +No forked Spring Boot or KSP artifact is used by the consumers. If upstream support lands, retest normal and +isolated `bootJar`, custom layer coordinates, included builds, IDE import and cache reuse before enabling +isolation globally. diff --git a/PERFORMANCE_AUDIT.md b/PERFORMANCE_AUDIT.md index a5a7fdf..dd1dc2e 100644 --- a/PERFORMANCE_AUDIT.md +++ b/PERFORMANCE_AUDIT.md @@ -98,10 +98,10 @@ 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. | 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. | +| 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 in 0.12.0 as 32 hash-assigned source files, each with its own resource; one file per template was measured and rejected. See the per-template section below. | +| Medium | Each expression property lookup can walk KSP properties and supertypes again, including properties from shared layouts. | Implemented in 0.12.1 as a cache confined to one processor invocation. | +| Medium | Generated `supports`, `supportsReturnType`, request-data checks, and render dispatch use linear checks. | Implemented in 0.11.2 as a class index; 0.13.0 splits the dispatch so it stays JIT-compilable. See the 0.11.2 follow-up and 0.13.0 sections below. | +| Medium | Servlet rendering allocates an 8 KiB body buffer and a 1 KiB output buffer, then buffers the whole response. | Measured in the 0.11.2 follow-up and left unchanged: larger buffers cost 30–70% on small HTMX responses. Streaming changes failure handling and content-length behavior, so it needs separate design work. | | Lower | CSS and message-usage checks scan production sources/classes across all modules. | Measure their actual task time and cache hit rates in ReAI. Both tasks are already cacheable; reducing scope must preserve cross-module validation. | The existing local generated-file sizes are supporting evidence of compilation scope, @@ -245,7 +245,7 @@ replacing the form-error stream with a loop, and using a linear string join for source arguments. None has evidence of a substantial benefit in these consumers; the Java-only compilation path is not used by these four Kotlin applications. The larger property-resolution, dispatch, response-buffering, and incremental-compilation -ideas above still need dedicated profiling and compatibility work. +ideas were followed up in the later sections. Reproduce the message measurements with: @@ -446,3 +446,40 @@ override was also left unchanged: ordinary Gradle builds do not validate debugge Raw measurements, including all warmups and the isolated candidates, are in [`2026-09-19-compiler-small-wins.json`](benchmark/results/2026-09-19-compiler-small-wins.json). + +## 0.13.0 — registry dispatch below the JIT limit — 23 September 2026 + +The 0.11.2 index made dispatch constant time in a replica benchmark, but the shipped +registry kept the subclass `instanceof` chain in the same `render` method as the index +switch. That costs about 37 bytes of bytecode per template, so from roughly 215 templates +the method exceeds HotSpot's 8000-byte `HugeMethodLimit` and, with the default +`-XX:+DontCompileHugeMethods`, is never JIT-compiled. ReAI's web-app registry is 353 +templates with a 13,137-byte `render`, and production uses default JIT flags. Bedri +(1,334 bytes) and Eteo (1,001 bytes) were below the limit. + +JMH replica of the generated shape at 353 templates (Apple M5 Max, OpenJDK 27, three +forks, five one-second warmups and measurements, models visited in a stride-7 cycle; the +host was under unrelated load, which widens the interpreted error): + +| Registry shape | ns/op | +| --- | ---: | +| 0.12.2: index switch and `instanceof` chain in `render` | 195.1 ± 142.9 | +| 0.12.2 with `-XX:-DontCompileHugeMethods` | 11.9 ± 0.6 | +| 0.13.0: dispatcher, 256-template chunks, separate fallback | 10.2 ± 0.2 | + +The control run attributes the whole difference to the method being interpreted. The +generated registry now has a small `render` that switches on `index / 256` to private +chunk methods (about 4 KB each at most), while the unchanged `instanceof` chain lives in +`renderSubtype` and the `isAssignableFrom` chain in `isSupertypeOfModel`. Both are off the +exact-class hot path. The fallback chain keeps every model because KSP's `isOpen()` cannot +see all-open compiler plugins or Java library classes, so dropping "final" models would +not be provably safe. With ReAI compiled against the candidate, the largest methods are +`renderSubtype` at 6,727 bytes, `render0` at 4,127 bytes and `isSupertypeOfModel` at 3,419 bytes; +`render` is 108 bytes. + +Validation: ReAI's web-app generated output against 0.12.2 differs only in +`ThimTemplates.java`; all 32 renderer part sources, their resources and the message usage +manifest are byte-identical. ReAI (web-app, bedri), Utin, Eteo and Ecomtools compile +against the candidate via `-PthimBuild`. `RegistryGeneratorTest` compiles a 600-template +registry with an open model and a subclass, renders every model, and keeps each +per-request method below 8000 bytes; the example golden renders are unchanged. diff --git a/README.md b/README.md index 7dedc9e..00475b9 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ pluginManagement { } plugins { - id("no.beint.thim.settings") version "0.11.2" + id("no.beint.thim.settings") version "0.13.0" } ``` @@ -93,7 +93,7 @@ plugins { } ``` -The plugin supplies the dependency-free runtime and the build-time compiler, and tracks templates and messages as compilation inputs. When the Spring Boot plugin is present it also adds the Spring MVC adapter automatically, regardless of plugin application order. Plain Spring applications can opt in explicitly with `implementation("no.beint.thim:spring:0.11.2")`. Compiled template jars publish their registries through Java's service loader, so templates can live in any application module. +The plugin supplies the dependency-free runtime and the build-time compiler, and tracks templates and messages as compilation inputs. When the Spring Boot plugin is present it also adds the Spring MVC adapter automatically, regardless of plugin application order. Plain Spring applications can opt in explicitly with `implementation("no.beint.thim:spring:0.13.0")`. Compiled template jars publish their registries through Java's service loader, so templates can live in any application module. ```kotlin thim { @@ -328,7 +328,7 @@ Its default fixtures contain 100 and 1,000 fragment calls. To measure an applica ```shell ./gradlew :benchmark:jmhJar -java -jar benchmark/build/libs/benchmark-0.11.2-jmh.jar TemplateCompilerBenchmark \ +java -jar benchmark/build/libs/benchmark-0.13.0-jmh.jar TemplateCompilerBenchmark \ -p templatesDirectory=/absolute/path/to/src/main/resources/templates -p elements=100 \ -f 2 -prof gc ``` diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/MessageGenerator.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/MessageGenerator.kt index 519660f..6daf2e7 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/MessageGenerator.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/MessageGenerator.kt @@ -288,7 +288,7 @@ internal class MessageGenerator(private val catalog: MessageCatalog) { return javaIdentifier(candidate, "message") } - private fun words(value: String): List = value.split(Regex("[^A-Za-z0-9]+")) + private fun words(value: String): List = value.split(NON_ALPHANUMERIC) .filter(String::isNotEmpty) private fun javaIdentifier(value: String, fallback: String): String { diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/RegistryGenerator.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/RegistryGenerator.kt new file mode 100644 index 0000000..8146a34 --- /dev/null +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RegistryGenerator.kt @@ -0,0 +1,111 @@ +package no.beint.thim.compiler + +/** One compiled page model: its class, the generated renderer that serves it, and its request-data use. */ +internal data class RegistryEntry( + val modelName: String, + val rendererReference: String, + val usesRequestDataValues: Boolean, +) + +/** Generates the module's [no.beint.thim.TemplateSet], which dispatches page models to their renderers. */ +internal class RegistryGenerator(private val entries: List) { + fun generate(packageName: String, className: String): String = buildString { + val chunks = entries.withIndex().chunked(DISPATCH_CHUNK) + appendLine("package $packageName;") + appendLine() + appendLine("import java.io.IOException;") + appendLine("import no.beint.thim.HtmlOutput;") + appendLine("import no.beint.thim.RenderContext;") + appendLine("import no.beint.thim.TemplateSet;") + appendLine() + appendLine("public final class $className implements TemplateSet {") + appendLine(" // Exact page-model classes resolve in constant time; the instanceof chain in renderSubtype") + appendLine(" // only serves subclasses of open models, matching the previous linear dispatch.") + appendLine(" private static final java.util.Map, Integer> INDEX = index();") + appendLine() + appendLine(" // Built imperatively: javac's inference over one Map.ofEntries call with hundreds of") + appendLine(" // distinct Class arguments took several seconds for a large application.") + appendLine(" private static java.util.Map, Integer> index() {") + appendLine(" var index = new java.util.HashMap, Integer>(${entries.size * 2});") + entries.forEachIndexed { index, template -> + appendLine(" index.put(${template.modelName}.class, $index);") + } + appendLine(" return java.util.Map.copyOf(index);") + appendLine(" }") + appendLine() + appendLine(" private static final boolean[] REQUEST_DATA_VALUES = {") + appendLine(" " + entries.joinToString(", ") { it.usesRequestDataValues.toString() }) + appendLine(" };") + appendLine() + appendLine(" @Override") + appendLine(" public boolean supports(Class modelType) {") + appendLine(" return INDEX.containsKey(modelType);") + appendLine(" }") + appendLine() + appendLine(" @Override") + appendLine(" public boolean supportsReturnType(Class returnType) {") + appendLine(" // Spring supplies the runtime type when a value exists; Object chiefly represents a null return.") + appendLine(" if (returnType == Object.class) {") + appendLine(" return false;") + appendLine(" }") + appendLine(" return INDEX.containsKey(returnType) || isSupertypeOfModel(returnType);") + appendLine(" }") + appendLine() + appendLine(" private static boolean isSupertypeOfModel(Class returnType) {") + appendLine(" return " + entries.joinToString(" ||\n ") { + "returnType.isAssignableFrom(${it.modelName}.class)" + } + ";") + appendLine(" }") + appendLine() + appendLine(" @Override") + appendLine(" public boolean usesRequestDataValues(Class modelType) {") + appendLine(" var index = INDEX.get(modelType);") + appendLine(" return index != null && REQUEST_DATA_VALUES[index];") + appendLine(" }") + appendLine() + appendLine(" // Dispatch is split so every method stays far below HotSpot's 8000-byte limit for JIT") + appendLine(" // compilation; one switch over every template left large applications interpreted.") + appendLine(" @Override") + appendLine(" public void render(Object model, RenderContext context, HtmlOutput output) throws IOException {") + appendLine(" var index = INDEX.get(model.getClass());") + appendLine(" if (index == null) {") + appendLine(" renderSubtype(model, context, output);") + appendLine(" return;") + appendLine(" }") + appendLine(" switch (index / $DISPATCH_CHUNK) {") + chunks.indices.forEach { chunk -> + appendLine(" case $chunk -> render$chunk(index, model, context, output);") + } + appendLine(" default -> throw new IllegalStateException(\"Unknown template index \" + index);") + appendLine(" }") + appendLine(" }") + chunks.forEachIndexed { chunk, templates -> + appendLine() + appendLine(" private static void render$chunk(int index, Object model, RenderContext context, HtmlOutput output) throws IOException {") + appendLine(" switch (index) {") + templates.forEach { (index, template) -> + appendLine(" case $index -> ${template.rendererReference}.render((${template.modelName}) model, context, output);") + } + appendLine(" default -> throw new IllegalStateException(\"Unknown template index \" + index);") + appendLine(" }") + appendLine(" }") + } + appendLine() + appendLine(" // Only instances of subclasses of open page models reach this chain.") + appendLine(" private static void renderSubtype(Object model, RenderContext context, HtmlOutput output) throws IOException {") + entries.forEach { + appendLine(" if (model instanceof ${it.modelName} typed) {") + appendLine(" ${it.rendererReference}.render(typed, context, output);") + appendLine(" return;") + appendLine(" }") + } + appendLine(" throw new IllegalArgumentException(\"No compiled template for \" + model.getClass().getName());") + appendLine(" }") + appendLine("}") + } + + internal companion object { + /** Templates per dispatch method: at most about 4 KB of bytecode, half HotSpot's JIT limit. */ + const val DISPATCH_CHUNK = 256 + } +} 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 1a48a31..b3f3808 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt @@ -69,7 +69,7 @@ internal class RendererGenerator( // 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 rendererName = modelName.replace(NON_IDENTIFIER_CHARACTER, "_") + "Renderer" val code = CodeWriter(staticContent, holderName) val locales = if (usesMessages(nodes)) { catalog.supportedLocales.filterTo(linkedSetOf()) { it != catalog.defaultLocale } @@ -1646,3 +1646,5 @@ private fun KSType.isBoolean(): Boolean = private fun KSType.isEnum(): Boolean = (declaration as? KSClassDeclaration)?.classKind == ClassKind.ENUM_CLASS + +private val NON_IDENTIFIER_CHARACTER = Regex("[^A-Za-z0-9_]") diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/RouteCatalog.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/RouteCatalog.kt index 41c279a..73437ce 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/RouteCatalog.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RouteCatalog.kt @@ -38,8 +38,6 @@ internal class RouteCatalog( private val matchingRoutes = mutableMapOf>() private val trustedPatterns: List> = trustedPaths.map(::trustedPattern) - fun isEmpty(): Boolean = routes.isEmpty() - fun check( path: String, httpMethod: String, @@ -299,7 +297,7 @@ internal class RouteCatalog( }.takeIf { !it.isNullOrBlank() } private fun combine(prefix: String, path: String): String { - val joined = "/${prefix.trim('/')}/${path.trim('/')}".replace(Regex("/+"), "/") + val joined = "/${prefix.trim('/')}/${path.trim('/')}".replace(REPEATED_SLASHES, "/") return if (joined.length > 1) joined.trimEnd('/') else joined } @@ -361,3 +359,5 @@ internal class RouteCatalog( } } + +private val REPEATED_SLASHES = Regex("/+") diff --git a/compiler/src/main/kotlin/no/beint/thim/compiler/RouteGenerator.kt b/compiler/src/main/kotlin/no/beint/thim/compiler/RouteGenerator.kt index 00eb9c6..6a9205e 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/RouteGenerator.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RouteGenerator.kt @@ -121,7 +121,7 @@ internal class RouteGenerator(private val catalog: RouteCatalog) { appendLine() } - private fun words(value: String): List = value.split(Regex("[^A-Za-z0-9]+")) + private fun words(value: String): List = value.split(NON_ALPHANUMERIC) .filter(String::isNotEmpty) private fun camelWord(value: String): String = words(value).joinToString("") { 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 ffef03a..789247b 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/ThimProcessor.kt @@ -238,80 +238,10 @@ private class ThimProcessor( fileName = registryName, 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("import no.beint.thim.TemplateSet;") - output.appendLine() - output.appendLine("public final class $registryName implements TemplateSet {") - 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();") - output.appendLine() - output.appendLine(" // Built imperatively: javac's inference over one Map.ofEntries call with hundreds of") - output.appendLine(" // distinct Class arguments took several seconds for a large application.") - output.appendLine(" private static java.util.Map, Integer> index() {") - output.appendLine(" var index = new java.util.HashMap, Integer>(${compiled.size * 2});") - compiled.forEachIndexed { index, template -> - output.appendLine(" index.put(${template.model.qualifiedName!!.asString()}.class, $index);") + val entries = compiled.map { + RegistryEntry(it.model.qualifiedName!!.asString(), rendererReference(it), it.usesRequestDataValues) } - output.appendLine(" return java.util.Map.copyOf(index);") - output.appendLine(" }") - output.appendLine() - output.appendLine(" private static final boolean[] REQUEST_DATA_VALUES = {") - output.appendLine(" " + compiled.joinToString(", ") { it.usesRequestDataValues.toString() }) - output.appendLine(" };") - output.appendLine() - output.appendLine(" @Override") - output.appendLine(" public boolean supports(Class modelType) {") - output.appendLine(" return INDEX.containsKey(modelType);") - output.appendLine(" }") - output.appendLine() - output.appendLine(" @Override") - output.appendLine(" public boolean supportsReturnType(Class returnType) {") - output.appendLine(" // Spring supplies the runtime type when a value exists; Object chiefly represents a null return.") - output.appendLine(" if (returnType == Object.class) {") - output.appendLine(" return false;") - output.appendLine(" }") - output.appendLine(" if (INDEX.containsKey(returnType)) {") - output.appendLine(" return true;") - output.appendLine(" }") - output.appendLine(" return " + compiled.joinToString(" ||\n ") { - "returnType.isAssignableFrom(${it.model.qualifiedName!!.asString()}.class)" - } + ";") - output.appendLine(" }") - output.appendLine() - output.appendLine(" @Override") - output.appendLine(" public boolean usesRequestDataValues(Class modelType) {") - output.appendLine(" var index = INDEX.get(modelType);") - output.appendLine(" return index != null && REQUEST_DATA_VALUES[index];") - output.appendLine(" }") - output.appendLine() - output.appendLine(" @Override") - output.appendLine(" public void render(Object model, RenderContext context, HtmlOutput output) throws IOException {") - output.appendLine(" var index = INDEX.get(model.getClass());") - output.appendLine(" if (index != null) {") - output.appendLine(" switch (index) {") - compiled.forEachIndexed { index, template -> - val modelName = template.model.qualifiedName!!.asString() - output.appendLine(" case $index -> ${rendererReference(template)}.render(($modelName) model, context, output);") - } - output.appendLine(" default -> throw new IllegalStateException(\"Unknown template index \" + index);") - output.appendLine(" }") - output.appendLine(" return;") - output.appendLine(" }") - compiled.forEach { - val modelName = it.model.qualifiedName!!.asString() - output.appendLine(" if (model instanceof $modelName typed) {") - output.appendLine(" ${rendererReference(it)}.render(typed, context, output);") - output.appendLine(" return;") - output.appendLine(" }") - } - output.appendLine(" throw new IllegalArgumentException(\"No compiled template for \" + model.getClass().getName());") - output.appendLine(" }") - output.appendLine("}") + output.append(RegistryGenerator(entries).generate(generatedPackage, registryName)) } codeGenerator.createNewFileByPath( dependencies = dependencies, @@ -429,7 +359,7 @@ private class ThimProcessor( const val RENDERER_FILES = 32 fun conventionalModelName(templateName: String): String = templateName - .split(Regex("[^A-Za-z0-9]+")) + .split(NON_ALPHANUMERIC) .filter(String::isNotEmpty) .joinToString("") { it.replaceFirstChar(Char::uppercaseChar) } + "Page" } @@ -442,3 +372,6 @@ internal fun Node.elements(): Sequence = when (this) { is ElementNode -> sequenceOf(this) + children.asSequence().flatMap(Node::elements) is RawNode -> emptySequence() } + +/** Word separators for generated identifiers, compiled once rather than per catalog key or template. */ +internal val NON_ALPHANUMERIC = Regex("[^A-Za-z0-9]+") diff --git a/compiler/src/test/kotlin/no/beint/thim/compiler/RegistryGeneratorTest.kt b/compiler/src/test/kotlin/no/beint/thim/compiler/RegistryGeneratorTest.kt new file mode 100644 index 0000000..cd9f9aa --- /dev/null +++ b/compiler/src/test/kotlin/no/beint/thim/compiler/RegistryGeneratorTest.kt @@ -0,0 +1,106 @@ +package no.beint.thim.compiler + +import no.beint.thim.HtmlOutput +import no.beint.thim.RenderContext +import no.beint.thim.TemplateSet +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayOutputStream +import java.io.StringWriter +import java.lang.classfile.ClassFile +import java.lang.classfile.attribute.CodeAttribute +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.util.Locale +import javax.tools.ToolProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RegistryGeneratorTest { + @TempDir + lateinit var directory: Path + + @Test + fun `large registries dispatch every model through JIT-compilable methods`() { + val models = 600 + val source = Files.createDirectories(directory.resolve("src/example")) + val renderers = buildString { + appendLine("package example;") + appendLine("final class Renderers {") + (0..models).forEach { index -> + val model = if (index == models) "Open" else "M$index" + appendLine(" static final class R$index {") + appendLine(" static void render($model model, no.beint.thim.RenderContext context, no.beint.thim.HtmlOutput output) throws java.io.IOException {") + appendLine(" output.text($index);") + appendLine(" }") + appendLine(" }") + } + appendLine("}") + } + Files.writeString(source.resolve("Renderers.java"), renderers) + Files.writeString(source.resolve("Models.java"), buildString { + appendLine("package example;") + appendLine("interface Page {}") + (0 until models).forEach { appendLine("final class M$it implements Page {}") } + appendLine("class Open {}") + appendLine("final class OpenChild extends Open {}") + }) + val entries = (0 until models).map { RegistryEntry("example.M$it", "Renderers.R$it", it % 2 == 0) } + + RegistryEntry("example.Open", "Renderers.R$models", false) + Files.writeString(source.resolve("Registry.java"), RegistryGenerator(entries).generate("example", "Registry")) + + val classes = Files.createDirectory(directory.resolve("classes")) + val compiler = ToolProvider.getSystemJavaCompiler() + val diagnostics = StringWriter() + compiler.getStandardFileManager(null, null, null).use { files -> + val runtime = Path.of(TemplateSet::class.java.protectionDomain.codeSource.location.toURI()) + val sources = listOf("Renderers.java", "Models.java", "Registry.java").map(source::resolve) + assertTrue(compiler.getTask(diagnostics, files, null, + listOf("-classpath", runtime.toString(), "-d", classes.toString()), null, + files.getJavaFileObjects(*sources.toTypedArray())).call(), diagnostics.toString()) + } + + // Methods on the per-request path; the one-time index initializer and the subclass fallback may grow. + val hot = ClassFile.of().parse(classes.resolve("example/Registry.class")).methods().filter { + val name = it.methodName().stringValue() + name in setOf("supports", "supportsReturnType", "usesRequestDataValues", "render") || name.matches(Regex("render\\d+")) + } + assertEquals(7, hot.size) + hot.forEach { method -> + val length = (method.code().orElseThrow() as CodeAttribute).codeLength() + assertTrue(length < 8_000, "${method.methodName().stringValue()} has $length bytes of bytecode") + } + + URLClassLoader(arrayOf(classes.toUri().toURL()), javaClass.classLoader).use { loader -> + val registry = loader.loadClass("example.Registry").getConstructor().newInstance() as TemplateSet + fun render(model: Any): String { + val bytes = ByteArrayOutputStream() + val output = HtmlOutput(bytes) + registry.render(model, RenderContext(Locale.ENGLISH, ""), output) + output.flush() + return bytes.toString(Charsets.UTF_8) + } + fun instance(name: String): Any = loader.loadClass("example.$name") + .getDeclaredConstructor().apply { isAccessible = true }.newInstance() + + (0 until models).forEach { index -> + val model = instance("M$index") + assertEquals(index.toString(), render(model)) + assertTrue(registry.supports(model.javaClass)) + assertEquals(index % 2 == 0, registry.usesRequestDataValues(model.javaClass)) + } + val child = instance("OpenChild") + assertEquals(models.toString(), render(child)) + assertFalse(registry.supports(child.javaClass)) + assertTrue(registry.supportsReturnType(loader.loadClass("example.Open"))) + assertTrue(registry.supportsReturnType(loader.loadClass("example.Page"))) + assertFalse(registry.supportsReturnType(Any::class.java)) + assertFalse(registry.supportsReturnType(String::class.java)) + val failure = assertFailsWith { render("not a page") } + assertEquals("No compiled template for java.lang.String", failure.message) + } + } +} diff --git a/docs/index.html b/docs/index.html index 71699f7..1271c58 100644 --- a/docs/index.html +++ b/docs/index.html @@ -26,7 +26,7 @@ Thim - 0.11.1 + 0.13.0