diff --git a/README.md b/README.md index d62ca1b..1abec07 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Apply the plugin after the Kotlin JVM plugin in Kotlin modules: ```kotlin plugins { kotlin("jvm") - id("no.beint.thim") version "0.5.1" + id("no.beint.thim") version "0.6.0" } ``` @@ -54,7 +54,7 @@ Java modules need only the Java and Thim plugins: ```kotlin plugins { java - id("no.beint.thim") version "0.5.1" + id("no.beint.thim") version "0.6.0" } ``` diff --git a/build.gradle.kts b/build.gradle.kts index af4b682..cca44a4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ plugins { allprojects { group = "no.beint.thim" - version = "0.5.1" + version = "0.6.0" } subprojects { 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 ff5b6cb..3cd9075 100644 --- a/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt +++ b/compiler/src/main/kotlin/no/beint/thim/compiler/RendererGenerator.kt @@ -36,9 +36,12 @@ internal class RendererGenerator( private val strictModels: Boolean = false, ) { private var generatedVariable = 0 + private var generatedHelper = 0 private var regionalLocales = emptyMap() private var languages = emptyMap() private var formErrors: ResolvedPath? = null + private var hasMessageLocale = false + private val pendingHelpers = ArrayDeque() val errors = mutableListOf() private val usedRootProperties = mutableMapOf>() @@ -64,6 +67,10 @@ internal class RendererGenerator( } regionalLocales = localeIds.filterKeys { '-' in it } languages = localeIds.filterKeys { '-' !in it } + hasMessageLocale = regionalLocales.isNotEmpty() || languages.isNotEmpty() + generatedVariable = 0 + generatedHelper = 0 + pendingHelpers.clear() code.line("final class $rendererName {") code.indent { @@ -94,14 +101,66 @@ internal class RendererGenerator( usedRootProperties.getOrPut(modelName, ::mutableSetOf).add(property) }) formErrors = scope.errorsProperty() - nodes.forEach { renderNodeCollecting(it, scope, code, templateName) } + renderNodes(nodes, scope, code, templateName) } code.line("}") + while (pendingHelpers.isNotEmpty()) { + val helper = pendingHelpers.removeFirst() + code.line() + val localeParameter = if (hasMessageLocale) ", int messageLocale" else "" + val capturedParameters = helper.captures.joinToString("") { ", Object ${it.code}Value" } + code.line( + "private static void ${helper.name}($modelName model, RenderContext context, " + + "HtmlOutput output$localeParameter$capturedParameters) throws IOException {", + ) + code.indent { + helper.captures.forEach { binding -> + code.statement("var ${binding.code} = (${binding.castType()}) ${binding.code}Value;") + } + helper.nodes.forEach { renderNodeCollecting(it, helper.scope, code, helper.context) } + } + code.line("}") + } } code.line("}") return CompiledTemplate(model, rendererName, code.toString()) } + private fun renderNodes(nodes: List, scope: Scope, code: CodeWriter, context: String) { + val chunks = partition(nodes) + if (chunks == null) { + nodes.forEach { renderNodeCollecting(it, scope, code, context) } + return + } + val captures = scope.capturedBindings() + chunks.forEach { chunk -> + val name = "renderPart${generatedHelper++}" + pendingHelpers.addLast(RenderHelper(name, chunk, scope, context, captures)) + val localeArgument = if (hasMessageLocale) ", messageLocale" else "" + val capturedArguments = captures.joinToString("") { ", ${it.code}" } + code.statement("$name(model, context, output$localeArgument$capturedArguments);") + } + } + + private fun partition(nodes: List): List>? { + if (nodes.sumOf { it.renderWeight() } <= MAX_RENDER_PART_WEIGHT) return null + val result = mutableListOf>() + var current = mutableListOf() + var currentWeight = 0 + nodes.forEach { node -> + val weight = node.renderWeight() + if (weight > 0 && currentWeight > 0 && currentWeight + weight > MAX_RENDER_PART_WEIGHT) { + result += current + current = mutableListOf() + currentWeight = 0 + } + current += node + currentWeight += weight + } + if (current.isNotEmpty()) result += current + return result.takeIf { it.size > 1 } + } + private fun renderNode(node: Node, scope: Scope, code: CodeWriter, context: String) { when (node) { is RawNode -> code.static(node.value) @@ -231,7 +290,9 @@ internal class RendererGenerator( requireDiagnostic(resolved.type.declaration is KSClassDeclaration, "THIM-OBJECT-TYPE", attributeLocation) { "th:object must bind a class with properties" } - scope = scope.withSelection(Binding(resolved.code, resolved.type, false)) + val generatedName = "object${generatedVariable++}" + code.statement("var $generatedName = ${resolved.code};") + scope = scope.withSelection(Binding(generatedName, resolved.type, false)) } if (element.name == "select" && "th:field" in attributes) { @@ -241,7 +302,9 @@ internal class RendererGenerator( diagnosticContext(attributeLocation, "THIM-FIELD-SYNTAX", "th:field"), ) val resolved = scope.resolveField(path, attributeLocation) - scope = scope.withSelectValue(Binding(resolved.code, resolved.type, resolved.nullable)) + val generatedName = "select${generatedVariable++}" + code.statement("var $generatedName = ${resolved.code};") + scope = scope.withSelectValue(Binding(generatedName, resolved.type, resolved.nullable)) } val transparent = element.name == "th:block" @@ -292,7 +355,7 @@ internal class RendererGenerator( } else if (fieldExpansion?.content != null) { code.statement("output.text(${fieldExpansion.content});") } else if (text == null && safeHtml == null) { - element.children.forEach { renderNodeCollecting(it, scope, code, context) } + renderNodes(element.children, scope, code, context) } else if (safeHtml != null) { val attributeLocation = attributeLocation(element, "th:utext") if (safeHtml.trim().startsWith("#{")) { @@ -941,10 +1004,64 @@ internal class RendererGenerator( private data class Binding(val code: String, val type: KSType, val nullable: Boolean) + private fun Binding.castType(): String = javaType(type, nullable) + + private fun javaType(type: KSType, boxed: Boolean = false): String { + val name = type.declaration.qualifiedName?.asString() ?: return "java.lang.Object" + val primitive = when (name) { + "kotlin.Boolean" -> "boolean" to "java.lang.Boolean" + "kotlin.Byte" -> "byte" to "java.lang.Byte" + "kotlin.Short" -> "short" to "java.lang.Short" + "kotlin.Int" -> "int" to "java.lang.Integer" + "kotlin.Long" -> "long" to "java.lang.Long" + "kotlin.Char" -> "char" to "java.lang.Character" + "kotlin.Float" -> "float" to "java.lang.Float" + "kotlin.Double" -> "double" to "java.lang.Double" + else -> null + } + if (primitive != null) return if (boxed || type.nullability == Nullability.NULLABLE) primitive.second else primitive.first + val primitiveArray = when (name) { + "kotlin.BooleanArray" -> "boolean[]" + "kotlin.ByteArray" -> "byte[]" + "kotlin.ShortArray" -> "short[]" + "kotlin.IntArray" -> "int[]" + "kotlin.LongArray" -> "long[]" + "kotlin.CharArray" -> "char[]" + "kotlin.FloatArray" -> "float[]" + "kotlin.DoubleArray" -> "double[]" + else -> null + } + if (primitiveArray != null) return primitiveArray + if (name == "kotlin.Array") { + val element = type.arguments.firstOrNull()?.type?.resolve() + return (element?.let { javaType(it) } ?: "java.lang.Object") + "[]" + } + val javaName = when (name) { + "kotlin.String" -> "java.lang.String" + "kotlin.Any" -> "java.lang.Object" + "kotlin.collections.Iterable" -> "java.lang.Iterable" + "kotlin.collections.Collection", "kotlin.collections.MutableCollection" -> "java.util.Collection" + "kotlin.collections.List", "kotlin.collections.MutableList" -> "java.util.List" + "kotlin.collections.Set", "kotlin.collections.MutableSet" -> "java.util.Set" + "kotlin.collections.Map", "kotlin.collections.MutableMap" -> "java.util.Map" + else -> name + } + val arguments = type.arguments.mapNotNull { it.type?.resolve() } + return if (arguments.isEmpty()) javaName else arguments.joinToString(", ", "$javaName<", ">") { javaType(it, boxed = true) } + } + private data class ResolvedPath(val code: String, val type: KSType, val nullable: Boolean) private data class Property(val type: KSType, val accessor: String) + private data class RenderHelper( + val name: String, + val nodes: List, + val scope: Scope, + val context: String, + val captures: List, + ) + private class Scope( private val model: KSClassDeclaration, private val recordUse: (String) -> Unit = {}, @@ -960,6 +1077,8 @@ internal class RendererGenerator( fun hasSelection(): Boolean = selection != null + fun capturedBindings(): List = (bindings.values + listOfNotNull(selection, select)).distinctBy(Binding::code) + fun selectValue(): Binding? = select fun errorsProperty(): ResolvedPath? { @@ -1169,6 +1288,20 @@ internal class RendererGenerator( } private companion object { + const val MAX_RENDER_PART_WEIGHT = 1200 + + fun Node.renderWeight(): Int = when (this) { + is RawNode -> 0 + is ElementNode -> 1 + attributes.values.sumOf { expression -> + when { + expression == null -> 0 + expression.trim().startsWith("#{") -> 12 + expression.trim().startsWith("@{") -> 8 + else -> 4 + } + } + children.sumOf { it.renderWeight() } + } + val eachPattern = Regex("([A-Za-z_][A-Za-z0-9_]*)\\s*:\\s*(\\$\\{.+})") val voidElements = setOf("area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr") val booleanAttributes = setOf( diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 2e73f09..b9ee6bc 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -24,6 +24,14 @@ dependencies { implementation(project(":spring")) implementation("org.springframework.boot:spring-boot-starter-webmvc:4.1.0") ksp(project(":compiler")) + + testImplementation(platform("org.junit:junit-bom:6.0.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() } ksp { diff --git a/example/src/main/kotlin/no/beint/thim/example/App.kt b/example/src/main/kotlin/no/beint/thim/example/App.kt index 9f27413..d98756a 100644 --- a/example/src/main/kotlin/no/beint/thim/example/App.kt +++ b/example/src/main/kotlin/no/beint/thim/example/App.kt @@ -52,7 +52,7 @@ data class FeedbackForm( class HomeCtrl { @GetMapping("/") fun home() = HomePage( - version = "0.4.0", + version = "0.6.0", greeting = "Typed models, compiled HTML, no runtime engine.", features = listOf( Feature("Safe", "Properties and messages are checked while the application compiles."), diff --git a/example/src/main/kotlin/no/beint/thim/example/page/LargePage.kt b/example/src/main/kotlin/no/beint/thim/example/page/LargePage.kt new file mode 100644 index 0000000..ad9a57b --- /dev/null +++ b/example/src/main/kotlin/no/beint/thim/example/page/LargePage.kt @@ -0,0 +1,3 @@ +package no.beint.thim.example.page + +data class LargePage(val value: String) diff --git a/example/src/main/resources/templates/large.html b/example/src/main/resources/templates/large.html new file mode 100644 index 0000000..92d577c --- /dev/null +++ b/example/src/main/resources/templates/large.html @@ -0,0 +1,408 @@ + + +Large renderer regression fixture + +
+value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +value +
+ + diff --git a/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java b/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java new file mode 100644 index 0000000..4416155 --- /dev/null +++ b/example/src/test/java/no/beint/thim/example/GeneratedRendererTest.java @@ -0,0 +1,28 @@ +package no.beint.thim.example; + +import java.io.IOException; +import java.lang.classfile.ClassFile; +import java.lang.classfile.attribute.CodeAttribute; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GeneratedRendererTest { + @Test + void partitionsLargeRenderersBelowTheHotSpotHugeMethodThreshold() throws IOException { + var resource = "/no/beint/thim/example/generated/no_beint_thim_example_page_LargePageThimRenderer.class"; + byte[] bytes; + try (var input = getClass().getResourceAsStream(resource)) { + bytes = input.readAllBytes(); + } + var methods = ClassFile.of().parse(bytes).methods().stream() + .filter(method -> method.methodName().stringValue().startsWith("render")) + .toList(); + + assertTrue(methods.size() > 2, "large fixture should exercise renderer partitioning"); + methods.forEach(method -> assertTrue( + ((CodeAttribute) method.code().orElseThrow()).codeLength() < 8_000, + () -> method.methodName().stringValue() + " exceeds HotSpot's huge-method threshold")); + } +} diff --git a/spring/build.gradle.kts b/spring/build.gradle.kts index 97ca46f..5f2056f 100644 --- a/spring/build.gradle.kts +++ b/spring/build.gradle.kts @@ -8,4 +8,14 @@ dependencies { api("org.springframework:spring-webmvc:7.0.8") compileOnly("org.springframework.boot:spring-boot-autoconfigure:4.1.0") compileOnly("jakarta.servlet:jakarta.servlet-api:6.1.0") + + testImplementation(platform("org.junit:junit-bom:6.0.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.springframework:spring-test:7.0.8") + testImplementation("jakarta.servlet:jakarta.servlet-api:6.1.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() } diff --git a/spring/src/main/java/no/beint/thim/spring/ThimRenderer.java b/spring/src/main/java/no/beint/thim/spring/ThimRenderer.java index a4bc67e..9a383a5 100644 --- a/spring/src/main/java/no/beint/thim/spring/ThimRenderer.java +++ b/spring/src/main/java/no/beint/thim/spring/ThimRenderer.java @@ -8,14 +8,16 @@ import org.springframework.web.servlet.support.RequestContextUtils; import org.springframework.web.servlet.support.RequestDataValueProcessor; -import java.io.IOException; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Locale; import java.util.ServiceLoader; public final class ThimRenderer { + private static final int OUTPUT_BUFFER_SIZE = 1024; + private final List templates; private final RequestDataValueProcessor requestDataValueProcessor; @@ -37,27 +39,36 @@ public ThimRenderer(List templates, RequestDataValueProcessor reque } public boolean supports(Class modelType) { - return templates.stream().anyMatch(templateSet -> templateSet.supports(modelType)); + for (var templateSet : templates) { + if (templateSet.supports(modelType)) { + return true; + } + } + return false; } public boolean supportsReturnType(Class returnType) { - return templates.stream().anyMatch(templateSet -> templateSet.supportsReturnType(returnType)); + for (var templateSet : templates) { + if (templateSet.supportsReturnType(returnType)) { + return true; + } + } + return false; } public void render(Object model, HttpServletRequest request, HttpServletResponse response) throws IOException { - var templateSet = templates.stream() - .filter(candidate -> candidate.supports(model.getClass())) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("No compiled template for " + model.getClass().getName())); + var templateSet = templateSetFor(model); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); response.setContentType("text/html"); - var output = new HtmlOutput(response.getOutputStream()); + var output = new HtmlOutput(response.getOutputStream(), OUTPUT_BUFFER_SIZE); + var locale = RequestContextUtils.getLocale(request); + var contextPath = request.getContextPath(); + var context = requestDataValueProcessor == null + ? new RenderContext(locale, contextPath) + : new RenderContext(locale, contextPath, new SpringRequestDataValues(request, requestDataValueProcessor)); templateSet.render( model, - new RenderContext( - RequestContextUtils.getLocale(request), - request.getContextPath(), - new SpringRequestDataValues(request, requestDataValueProcessor)), + context, output); output.flush(); } @@ -67,14 +78,28 @@ public String renderToString(Object model, Locale locale) throws IOException { } public String renderToString(Object model, Locale locale, String contextPath) throws IOException { - var templateSet = templates.stream() - .filter(candidate -> candidate.supports(model.getClass())) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("No compiled template for " + model.getClass().getName())); - var bytes = new ByteArrayOutputStream(); - var output = new HtmlOutput(bytes); + var templateSet = templateSetFor(model); + var bytes = new ByteArrayOutputStream(4096); + var output = new HtmlOutput(bytes, OUTPUT_BUFFER_SIZE); templateSet.render(model, new RenderContext(locale, contextPath), output); output.flush(); return bytes.toString(StandardCharsets.UTF_8); } + + private TemplateSet templateSetFor(Object model) { + var modelType = model.getClass(); + if (templates.size() == 1) { + var templateSet = templates.getFirst(); + if (templateSet.supports(modelType)) { + return templateSet; + } + } else { + for (var templateSet : templates) { + if (templateSet.supports(modelType)) { + return templateSet; + } + } + } + throw new IllegalArgumentException("No compiled template for " + modelType.getName()); + } } diff --git a/spring/src/test/java/no/beint/thim/spring/ThimRendererTest.java b/spring/src/test/java/no/beint/thim/spring/ThimRendererTest.java new file mode 100644 index 0000000..0cb0c12 --- /dev/null +++ b/spring/src/test/java/no/beint/thim/spring/ThimRendererTest.java @@ -0,0 +1,57 @@ +package no.beint.thim.spring; + +import no.beint.thim.HtmlOutput; +import no.beint.thim.RenderContext; +import no.beint.thim.RequestDataValues; +import no.beint.thim.TemplateSet; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class ThimRendererTest { + @Test + void rendersThroughTheSingleTemplateSetWithoutRequestProcessorAllocation() throws IOException { + var templates = new RecordingTemplateSet(); + var renderer = new ThimRenderer(List.of(templates)); + var request = new MockHttpServletRequest(); + request.setContextPath("/app"); + var response = new MockHttpServletResponse(); + + renderer.render("model", request, response); + + assertEquals(1, templates.supportsCalls); + assertSame(RequestDataValues.NONE, templates.context.requestDataValues()); + assertEquals("/app", templates.context.contextPath()); + assertEquals("rendered", response.getContentAsString(StandardCharsets.UTF_8)); + } + + private static final class RecordingTemplateSet implements TemplateSet { + private int supportsCalls; + private RenderContext context; + + @Override + public boolean supports(Class modelType) { + supportsCalls++; + return modelType == String.class; + } + + @Override + public boolean supportsReturnType(Class returnType) { + return supports(returnType); + } + + @Override + public void render(Object model, RenderContext context, HtmlOutput output) throws IOException { + this.context = context; + var bytes = "rendered".getBytes(StandardCharsets.UTF_8); + output.raw(bytes, 0, bytes.length); + } + } +}