diff --git a/docs/string-templates.md b/docs/string-templates.md index 24b383200..94c84c379 100644 --- a/docs/string-templates.md +++ b/docs/string-templates.md @@ -104,6 +104,21 @@ orm.query { "SELECT ${t(User::class)} FROM ${t(User::class)} WHERE id = ${t(id)} This produces identical behavior. The `t()` function is always available inside template lambdas. The compiler plugin simply automates the wrapping. Note that Storm cannot verify manual wrapping at runtime, so templates built without the plugin trigger the interpolation safety check described below. +### Constant Interpolations + +Every interpolation yields a bind value, including compile-time constants: + +```kotlin +const val DOMAIN = "%@gmail.com" + +orm.query { "SELECT ${User::class} FROM ${User::class} WHERE email LIKE ${"%@gmail.com"}" } +orm.query { "SELECT ${User::class} FROM ${User::class} WHERE email LIKE $DOMAIN" } +``` + +Both templates bind `%@gmail.com` exactly like a runtime value would. The Kotlin compiler folds constant expressions into the template text before the plugin runs; the plugin recovers them from the source and verifies the result against the folded value, so constants keep value semantics. In the rare case that a folded constant cannot be recovered, the plugin reports a compile error naming the expression; wrapping the interpolation in an explicit `t()` call resolves it. + +To contribute constant SQL text rather than a bind value, put the text in the template itself or concatenate literals with `+`. + ### Interpolation Safety When a `TemplateBuilder` lambda runs without the compiler plugin, Storm cannot verify that every string interpolation is wrapped in a `t()` or `interpolate()` call: a single unwrapped interpolation concatenates its value directly into the SQL. Explicit `t()` calls do not satisfy the check, because they say nothing about the other interpolations in the same template. The `storm.validation.interpolation_mode` system property controls how Storm handles templates it cannot verify: diff --git a/storm-compiler-plugin/src/main/kotlin-registrar-2.0/st/orm/kotlin/plugin/StormTemplatePluginRegistrar.kt b/storm-compiler-plugin/src/main/kotlin-registrar-2.0/st/orm/kotlin/plugin/StormTemplatePluginRegistrar.kt index 6de311f15..3ebb0a5ed 100644 --- a/storm-compiler-plugin/src/main/kotlin-registrar-2.0/st/orm/kotlin/plugin/StormTemplatePluginRegistrar.kt +++ b/storm-compiler-plugin/src/main/kotlin-registrar-2.0/st/orm/kotlin/plugin/StormTemplatePluginRegistrar.kt @@ -1,6 +1,8 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.cli.common.CLIConfigurationKeys +import org.jetbrains.kotlin.cli.common.messages.MessageCollector import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CompilerConfiguration @@ -29,6 +31,7 @@ class StormTemplatePluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean get() = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { - IrGenerationExtension.registerExtension(StormTemplateIrGenerationExtension()) + val messageCollector = configuration.get(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) + IrGenerationExtension.registerExtension(StormTemplateIrGenerationExtension(messageCollector)) } } diff --git a/storm-compiler-plugin/src/main/kotlin-registrar-2.3/st/orm/kotlin/plugin/StormTemplatePluginRegistrar.kt b/storm-compiler-plugin/src/main/kotlin-registrar-2.3/st/orm/kotlin/plugin/StormTemplatePluginRegistrar.kt index b07360c7a..e9136cc1b 100644 --- a/storm-compiler-plugin/src/main/kotlin-registrar-2.3/st/orm/kotlin/plugin/StormTemplatePluginRegistrar.kt +++ b/storm-compiler-plugin/src/main/kotlin-registrar-2.3/st/orm/kotlin/plugin/StormTemplatePluginRegistrar.kt @@ -1,8 +1,10 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.cli.common.messages.MessageCollector import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.config.CommonConfigurationKeys import org.jetbrains.kotlin.config.CompilerConfiguration /** @@ -31,6 +33,7 @@ class StormTemplatePluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean get() = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { - IrGenerationExtension.registerExtension(StormTemplateIrGenerationExtension()) + val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) + IrGenerationExtension.registerExtension(StormTemplateIrGenerationExtension(messageCollector)) } } diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index 165528023..7f17589ac 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -4,24 +4,33 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageLocation +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.ir.IrElement import org.jetbrains.kotlin.ir.builders.irCall import org.jetbrains.kotlin.ir.builders.irConcat import org.jetbrains.kotlin.ir.builders.irGet import org.jetbrains.kotlin.ir.declarations.IrFile import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.expressions.IrBlock +import org.jetbrains.kotlin.ir.expressions.IrBlockBody import org.jetbrains.kotlin.ir.expressions.IrCall import org.jetbrains.kotlin.ir.expressions.IrConst import org.jetbrains.kotlin.ir.expressions.IrExpression import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression -import org.jetbrains.kotlin.ir.expressions.IrBlockBody +import org.jetbrains.kotlin.ir.expressions.IrReturn import org.jetbrains.kotlin.ir.expressions.IrStringConcatenation +import org.jetbrains.kotlin.ir.expressions.IrTypeOperatorCall +import org.jetbrains.kotlin.ir.expressions.IrWhen import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI import org.jetbrains.kotlin.ir.types.IrType import org.jetbrains.kotlin.ir.types.classFqName import org.jetbrains.kotlin.ir.util.functions import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name @@ -40,6 +49,12 @@ import org.jetbrains.kotlin.name.Name * inside an interpolation yields a value rather than SQL, so its own interpolations and concatenations are left to * Kotlin: `"... LIKE ${"%" + name + "%"}"` interpolates a single string. * + * Constant interpolations like `${"value"}` are folded into the surrounding template text by the Kotlin compiler + * before this transformer runs. The transformer parses the source text behind such folded constants to split them + * back into SQL text and t()-wrapped values, and verifies the reassembled text against the constant's actual value. + * A folded constant that cannot be split provably-correctly is reported as a compiler error, so an interpolation + * can never silently remain SQL text. + * * Example transformation: * * Source: @@ -54,6 +69,7 @@ import org.jetbrains.kotlin.name.Name */ class StormTemplateIrTransformer( private val pluginContext: IrPluginContext, + private val messageCollector: MessageCollector = MessageCollector.NONE, ) : IrElementTransformerVoid() { companion object { @@ -81,17 +97,26 @@ class StormTemplateIrTransformer( /** Cached symbol for `TemplateContext.autoInterpolation()`. */ private var autoInterpolationSymbol: IrSimpleFunction? = null - /** Source text of the current file, cached for splitting merged constants. */ - private var currentSourceText: String? = null + /** The file being visited, for diagnostic locations. */ + private var currentFile: IrFile? = null + + /** Parser over the current file's source text, or null when the source cannot be read. */ + private var currentParser: TemplateSourceParser? = null + + /** Limits the unreadable-source warning to one per file. */ + private var unverifiableReported: Boolean = false override fun visitFile(declaration: IrFile): IrFile { - currentSourceText = try { - java.io.File(declaration.fileEntry.name).readText() + currentFile = declaration + currentParser = try { + TemplateSourceParser(java.io.File(declaration.fileEntry.name).readText()) } catch (_: Exception) { null } + unverifiableReported = false val result = super.visitFile(declaration) - currentSourceText = null + currentFile = null + currentParser = null return result } @@ -116,6 +141,10 @@ class StormTemplateIrTransformer( autoInterpolationSymbol = resolveAutoInterpolationFunction() } val result = super.visitFunctionExpression(expression) + // Recover templates that folded into a single constant; they have no concatenation for the visitor above. + tFunctionSymbol?.let { tFunction -> + function.body?.transformChildrenVoid(ResultConstantRewriter(function, extensionReceiver, tFunction)) + } // Inject autoInterpolation() call at the start of the lambda body to signal that the plugin is active. val autoInterpolation = autoInterpolationSymbol if (autoInterpolation != null) { @@ -155,6 +184,71 @@ class StormTemplateIrTransformer( return processConcatenation(concatenation, operatorConcatenation = true) } + /** + * Recovers templates that folded into a single string constant: a lambda whose interpolations are all constant + * yields a plain constant result with no concatenation for the visitor to rewrite. The rewrite is limited to + * the lambda's result positions, i.e. the returned expression and the expressions it derives from: branch + * results and call receivers such as a `trimIndent()` chain. Constants elsewhere in the lambda, e.g. messages + * inside nested lambdas, are not template text and keep their folded value. + */ + private inner class ResultConstantRewriter( + private val function: org.jetbrains.kotlin.ir.declarations.IrFunction, + private val receiver: IrValueParameter, + private val tFunction: IrSimpleFunction, + ) : IrElementTransformerVoid() { + + override fun visitReturn(expression: IrReturn): IrExpression { + val result = super.visitReturn(expression) + if (result is IrReturn && result.returnTargetSymbol == function.symbol) { + result.value = rewriteResultExpression(result.value) + } + return result + } + + private fun rewriteResultExpression(expression: IrExpression): IrExpression { + when (expression) { + is IrConst<*> -> if (expression.value is String) { + return replaceFoldedConstant(expression, receiver, tFunction) + } + is IrWhen -> expression.branches.forEach { branch -> + branch.result = rewriteResultExpression(branch.result) + } + is IrBlock -> { + val statements = expression.statements + val last = statements.lastOrNull() + if (last is IrExpression) { + statements[statements.size - 1] = rewriteResultExpression(last) + } + } + is IrTypeOperatorCall -> expression.argument = rewriteResultExpression(expression.argument) + is IrCall -> rewriteCallReceivers(expression) + else -> {} + } + return expression + } + + private fun rewriteCallReceivers(call: IrCall) { + call.dispatchReceiver?.let { call.dispatchReceiver = rewriteResultExpression(it) } + call.extensionReceiver?.let { call.extensionReceiver = rewriteResultExpression(it) } + } + } + + /** Splits a folded string constant into a concatenation of its template parts, or returns it unchanged. */ + private fun replaceFoldedConstant( + irConst: IrConst<*>, + receiver: IrValueParameter, + tFunction: IrSimpleFunction, + ): IrExpression { + val parts = classifyStringConst(irConst, enclosingKind = null, ConstPosition.STANDALONE, receiver, tFunction) + if (parts.size == 1 && parts[0] === irConst) { + return irConst + } + val builder = DeclarationIrBuilder(pluginContext, tFunction.symbol, irConst.startOffset, irConst.endOffset) + val concatenation = builder.irConcat() + concatenation.arguments.addAll(parts) + return concatenation + } + /** * Applies the template rules to the arguments of [expression]: literal text stays a fragment and every other * argument is wrapped in a `t()` call. @@ -170,17 +264,16 @@ class StormTemplateIrTransformer( ): IrExpression { val receiver = templateContextReceiver ?: return expression val tFunction = tFunctionSymbol ?: return expression + // The literal's own prefix determines how markers and escapes in its folded constants are interpreted. + val enclosingKind = if (operatorConcatenation) null else currentParser?.literalKindAt(expression.startOffset) // Recursively transform each argument first, so that nested TemplateBuilder lambdas (e.g., inside subquery - // calls) are processed before we wrap the argument in t(). + // calls) are processed before we wrap the argument in t(). Constants have no children to transform and are + // classified below instead, keeping their handling out of visitConst's standalone path. val newArguments = expression.arguments.flatMap { argument -> - val transformed = transformInPosition(argument, operatorConcatenation) + val transformed = if (argument is IrConst<*>) argument else transformInPosition(argument, operatorConcatenation) when { - transformed is IrConst<*> && isFragment(transformed) -> listOf(transformed) - transformed is IrConst<*> && hasMergedConstant(transformed) -> - splitMergedConstant(transformed, receiver, tFunction) - // A string literal operand of a `+` chain is SQL text, like the literal part of a string template. - // Any other constant is interpolated, so that `+ 42` and `${42}` produce the same bind value. - transformed is IrConst<*> && operatorConcatenation && transformed.value is String -> listOf(transformed) + transformed is IrConst<*> && transformed.value is String -> + classifyStringConst(transformed, enclosingKind, ConstPosition.of(operatorConcatenation), receiver, tFunction) transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) @@ -222,133 +315,82 @@ class StormTemplateIrTransformer( } /** - * Checks whether an [IrConst] is a string template fragment (literal SQL text) as opposed to an interpolated - * constant expression like `${"value"}`. + * Applies the template rules to a string constant in text position and returns the expressions that replace it. * - * Fragment [IrConst] entries have a source offset span that matches the text length, because they represent literal - * text from the template. Interpolated constant expressions have a larger offset span, since their source range - * includes the surrounding syntax (e.g., quotes for string literals). + * A constant whose source text spells out its value verbatim is literal template text and stays a fragment. Any + * other constant is handed to [TemplateSourceParser]: when its source region parses as template syntax and the + * reassembled text equals the constant's value, the folded `${"..."}` interpolations become t()-wrapped values + * and the rest stays text. A region that contains interpolation syntax but cannot be split that way is reported + * as a compiler error; regions without interpolation syntax (plain literals, `+` chain operands, escaped text) + * are left unchanged. */ - private fun isFragment(irConst: IrConst<*>): Boolean { - val value = irConst.value - if (value !is String) return false - return irConst.endOffset - irConst.startOffset == value.length - } - - /** - * Checks whether an [IrConst] contains a merged constant expression. This happens when the Kotlin compiler folds - * an inline constant expression like `${"value"}` with adjacent literal template text into a single [IrConst]. - * The merged entry has a source offset span larger than the text length, because the source range includes the - * `${"..."}` syntax in addition to the literal text. - */ - private fun hasMergedConstant(irConst: IrConst<*>): Boolean { - val value = irConst.value - if (value !is String) return false - return irConst.endOffset - irConst.startOffset > value.length - } - - /** - * Splits a merged [IrConst] (containing both literal template text and folded inline constant expressions) into - * separate fragment and wrapped expression entries. - * - * This method reads the source file to find `${"..."}` patterns within the [IrConst]'s source range, then splits - * the merged text accordingly. Each inline constant expression is wrapped in a `t()` call, while literal text - * fragments remain as plain [IrConst] entries. - * - * If the source file cannot be read or the parsing fails (e.g., due to escape sequences in the constant), the - * original [IrConst] is returned unchanged to avoid incorrect transformations. - */ - private fun splitMergedConstant( + private fun classifyStringConst( irConst: IrConst<*>, + enclosingKind: LiteralKind?, + position: ConstPosition, receiver: IrValueParameter, tFunction: IrSimpleFunction, ): List { - val sourceText = currentSourceText ?: return listOf(irConst) - val mergedText = irConst.value as String - val sourceStart = irConst.startOffset - val sourceEnd = irConst.endOffset - if (sourceStart < 0 || sourceEnd > sourceText.length) return listOf(irConst) - val source = sourceText.substring(sourceStart, sourceEnd) - // Find all ${"..."} patterns in the source and split the merged text. - val result = mutableListOf() - var textPosition = 0 - var sourcePosition = 0 - while (sourcePosition < source.length) { - val expressionStart = source.indexOf("\${\"", sourcePosition) - if (expressionStart == -1) break - // Add the fragment before the expression. - val fragmentSourceLength = expressionStart - sourcePosition - if (fragmentSourceLength > 0) { - val fragmentText = mergedText.substring(textPosition, textPosition + fragmentSourceLength) - val fragmentSource = source.substring(sourcePosition, expressionStart) - if (fragmentSource != fragmentText) { - // Fragment contains escape sequences; cannot reliably split. - return listOf(irConst) - } - result.add(createStringConst( - sourceStart + sourcePosition, - sourceStart + expressionStart, - irConst.type, - fragmentText, - )) - textPosition += fragmentSourceLength - } - // Parse the string literal inside ${"..."}. - val contentStart = expressionStart + 3 // position after ${" - val closingQuote = findClosingQuote(source, contentStart) - if (closingQuote == -1) return listOf(irConst) // Malformed; leave unchanged. - val expressionSourceContent = source.substring(contentStart, closingQuote) - if (expressionSourceContent.contains('\\')) { - // Expression contains escape sequences; cannot reliably determine the runtime value. - return listOf(irConst) + val value = irConst.value as String + val parser = currentParser + val start = irConst.startOffset + val end = irConst.endOffset + if (parser == null || start < 0 || end < start || !parser.inBounds(end)) { + // Without source text the constant cannot be verified. A concatenation argument whose source span does + // not match its value length either contains folded constants or escape sequences; neither can be told + // apart nor checked, which is worth one warning per file. Standalone constants are mostly ordinary + // literals whose span includes the quotes, so they stay silent. + if (position == ConstPosition.TEMPLATE_ARGUMENT && parser == null && end - start != value.length) { + reportUnverifiableTemplate(irConst) } - val expressionValueLength = expressionSourceContent.length - if (textPosition + expressionValueLength > mergedText.length) return listOf(irConst) - val expressionValue = mergedText.substring(textPosition, textPosition + expressionValueLength) - if (expressionValue != expressionSourceContent) { - // Mismatch between source and merged text; cannot reliably split. + return listOf(unverifiedConst(irConst, position, receiver, tFunction)) + } + if (parser.matchesSource(start, end, value)) { + // Literal template text. + return listOf(irConst) + } + val parts = parser.parse(start, end, enclosingKind) + if (parts != null && parts.joinToString("") { it.text } == value) { + if (parts.none { it is TemplateValue }) { + // Literal text whose source spelling differs from its value: escape sequences or quoted operands. return listOf(irConst) } - // Wrap the expression value in t(). - val expressionConst = createStringConst( - sourceStart + expressionStart + 2, // position of opening " - sourceStart + closingQuote + 1, // position after closing " - irConst.type, - expressionValue, - ) - result.add(wrapInT(expressionConst, receiver, tFunction)) - textPosition += expressionValueLength - sourcePosition = closingQuote + 2 // skip past "} - } - // Add remaining fragment after the last expression. - if (textPosition < mergedText.length) { - val remainingFragment = mergedText.substring(textPosition) - val remainingSource = source.substring(sourcePosition) - if (remainingSource != remainingFragment) { - // Fragment contains escape sequences; cannot reliably split. - return listOf(irConst) + return parts.map { part -> + val partConst = createStringConst(part.startOffset, part.endOffset, irConst.type, part.text) + if (part is TemplateValue) wrapInT(partConst, receiver, tFunction) else partConst } - result.add(createStringConst( - sourceStart + sourcePosition, - sourceEnd, - irConst.type, - remainingFragment, - )) - } - return if (result.isEmpty()) listOf(irConst) else result - } - - /** Finds the position of the closing quote (`"`) in a string literal, handling escaped quotes. */ - private fun findClosingQuote(source: String, fromIndex: Int): Int { - var i = fromIndex - while (i < source.length) { - when (source[i]) { - '"' -> return i - '\\' -> i++ // Skip escaped character. + } + if (parser.sawInterpolation) { + if (parser.isInterpolationFold(start)) { + // The compiler folded a constant expression in place of a single interpolation, e.g. a const val + // reference. Such folds never merge with the surrounding template text, so the constant's value is + // the interpolation's value and binds like any other interpolated argument. + return listOf(wrapInT(irConst, receiver, tFunction)) } - i++ + reportUnsplittableConstant(irConst, start, end) + return listOf(irConst) } - return -1 + return listOf(unverifiedConst(irConst, position, receiver, tFunction)) + } + + /** + * The fallback for a string constant the source cannot vouch for. A constant whose source span is shorter than + * its value cannot be a fragment of the template's literal text; as the interpolated argument of a string + * template it yields a bind value, matching the treatment of non-constant arguments. In every other position + * the constant stays text: chain operands are literal by the template rules, and standalone constants are + * ordinary literals whose span includes the quotes. + */ + private fun unverifiedConst( + irConst: IrConst<*>, + position: ConstPosition, + receiver: IrValueParameter, + tFunction: IrSimpleFunction, + ): IrExpression { + val value = irConst.value as String + if (position == ConstPosition.TEMPLATE_ARGUMENT && irConst.endOffset - irConst.startOffset < value.length) { + return wrapInT(irConst, receiver, tFunction) + } + return irConst } /** Creates a new string [IrConst] with the given offsets and value. */ @@ -401,6 +443,46 @@ class StormTemplateIrTransformer( body.statements.add(0, call) } + /** Reports a compiler error for a folded constant the plugin cannot split into template text and values. */ + private fun reportUnsplittableConstant(irConst: IrConst<*>, start: Int, end: Int) { + val snippet = currentParser?.snippet(start, end) ?: irConst.value.toString() + report( + CompilerMessageSeverity.ERROR, + "Storm compiler plugin cannot determine which parts of this SQL template are text and which are " + + "values: the Kotlin compiler folded a constant expression into the surrounding template text " + + "($snippet). Interpolate the constant with an explicit t() or interpolate() call, or inline it " + + "as a plain string literal.", + irConst, + ) + } + + /** Reports, once per file, that folded constants cannot be verified because the source is unreadable. */ + private fun reportUnverifiableTemplate(irConst: IrConst<*>) { + if (unverifiableReported) return + unverifiableReported = true + report( + CompilerMessageSeverity.WARNING, + "Storm compiler plugin cannot read the source of ${currentFile?.fileEntry?.name} to verify constant " + + "expressions folded into SQL templates; the folded constants are left as template text.", + irConst, + ) + } + + private fun report(severity: CompilerMessageSeverity, message: String, element: IrElement) { + val fileEntry = currentFile?.fileEntry + val location = if (fileEntry != null && element.startOffset >= 0) { + CompilerMessageLocation.create( + fileEntry.name, + fileEntry.getLineNumber(element.startOffset) + 1, + fileEntry.getColumnNumber(element.startOffset) + 1, + null, + ) + } else { + null + } + messageCollector.report(severity, message, location) + } + /** Resolves the `TemplateContext.t(Any?): String` function symbol. */ private fun resolveTFunction(): IrSimpleFunction? { val templateContextClass = pluginContext.referenceClass(TEMPLATE_CONTEXT_CLASS_ID) ?: return null @@ -415,3 +497,412 @@ class StormTemplateIrTransformer( .firstOrNull { it.name.asString() == "autoInterpolation" && it.valueParameters.isEmpty() } } } + +/** Where a string constant sits relative to the template being processed. */ +internal enum class ConstPosition { + /** An argument of a string template literal. */ + TEMPLATE_ARGUMENT, + + /** An operand of a `+` chain. */ + CHAIN_OPERAND, + + /** A constant that is not part of any concatenation. */ + STANDALONE; + + companion object { + fun of(operatorConcatenation: Boolean): ConstPosition = + if (operatorConcatenation) CHAIN_OPERAND else TEMPLATE_ARGUMENT + } +} + +/** + * A piece of a parsed template region. Offsets are absolute positions in the source file. + */ +internal sealed class TemplatePart { + abstract val startOffset: Int + abstract val endOffset: Int + abstract val text: String +} + +/** Literal SQL text. */ +internal class TemplateText( + override val startOffset: Int, + override val endOffset: Int, + override val text: String, +) : TemplatePart() + +/** A folded constant expression that yields a bind value. */ +internal class TemplateValue( + override val startOffset: Int, + override val endOffset: Int, + override val text: String, +) : TemplatePart() + +/** + * Interpolation syntax of a string literal: the number of dollar signs in an interpolation marker and whether the + * literal is raw (triple-quoted, no escape processing). + */ +internal data class LiteralKind(val dollars: Int, val raw: Boolean) + +/** + * Parses the source text behind a folded string constant back into the template pieces the constant was folded + * from: literal text and `${"..."}` constant expressions. + * + * Parsing is driven purely by the source text; the caller verifies the reassembled text against the constant's + * actual value, so a parse that would change the meaning of the template cannot go undetected. The parser handles + * the shapes the compiler produces: bare template content, content that starts at the inner literal of an + * interpolation whose `${` marker lies before the region, and complete quoted literals joined by `+`. Escape + * sequences are decoded in regular literals and taken verbatim in raw literals, and multi-dollar literals only + * treat runs of at least the marker's dollar count as interpolations. + */ +internal class TemplateSourceParser(private val fileText: String) { + + /** + * Set when the most recent [parse] encountered interpolation syntax, even if parsing subsequently failed. + * Distinguishes template-shaped source, whose failures must be loud, from plain constants. + */ + var sawInterpolation: Boolean = false + private set + + fun inBounds(offset: Int): Boolean = offset in 0..fileText.length + + /** Checks whether the source region [start] until [end] spells out [value] verbatim. */ + fun matchesSource(start: Int, end: Int, value: String): Boolean = + end - start == value.length && fileText.regionMatches(start, value, 0, value.length) + + /** A condensed, quoted rendering of the source region for diagnostics. */ + fun snippet(start: Int, end: Int): String { + val region = fileText.substring(start, end).replace("\n", "\\n") + return if (region.length <= 60) "'$region'" else "'${region.take(57)}...'" + } + + /** The literal kind of the string literal that starts at [offset], or null when the offset does not sit on one. */ + fun literalKindAt(offset: Int): LiteralKind? { + if (offset < 0 || offset >= fileText.length) return null + return literalPrefix(offset)?.first + } + + /** + * Checks whether the region at [start] is the folded form of a single interpolation: its source sits directly + * inside interpolation syntax, `$name` or `${name}`, rather than starting at a string literal. The compiler + * folds such constants in place of the interpolation without merging the surrounding template text, so the + * constant's value is the interpolation's value. Merged constants always start at literal syntax instead. + */ + fun isInterpolationFold(start: Int): Boolean { + if (literalPrefix(start) != null) return false + return markerBehind(start) != null || (start > 0 && fileText[start - 1] == '$') + } + + /** + * Parses the region [start] until [end] into template parts, or returns null when the region cannot be related + * to template syntax. [enclosingKind] is the kind of the string literal the region belongs to, when known. + */ + fun parse(start: Int, end: Int, enclosingKind: LiteralKind?): List? { + sawInterpolation = false + if (start < 0 || end < start || end > fileText.length) return null + markerBehind(start)?.let { dollars -> + // The region starts at the inner literal of an interpolation whose marker lies before it. + sawInterpolation = true + if (enclosingKind != null) { + return parseContinuation(start, end, LiteralKind(dollars, enclosingKind.raw)) + } + // The enclosing literal's kind is unknown; the value verification in the caller picks the attempt + // that reproduces the constant. + return parseContinuation(start, end, LiteralKind(dollars, raw = false)) + ?: parseContinuation(start, end, LiteralKind(dollars, raw = true)) + } + if (start > 0 && fileText[start - 1] == '$') { + // The region starts at the identifier of a simple-name interpolation like $CONST: the folded value + // cannot be recovered from the source. + sawInterpolation = true + return null + } + if (literalPrefix(start) != null) { + return parseLiteralChain(start, end) + } + val kind = enclosingKind ?: openingQuoteBehind(start) ?: return null + val parts = mutableListOf() + val consumed = scanContent(start, end, kind, parts, stopAtClosingQuote = false) ?: return null + if (consumed != end) return null + return parts + } + + /** Parses a region that starts inside an interpolation: the inner literal, its closing brace, then content. */ + private fun parseContinuation(start: Int, end: Int, kind: LiteralKind): List? { + val parts = mutableListOf() + val pos = parseInterpolationTail(start, parts) ?: return null + if (pos >= end) return parts // The region ends inside the interpolation's closing syntax. + val consumed = scanContent(pos, end, kind, parts, stopAtClosingQuote = false) ?: return null + if (consumed != end) return null + return parts + } + + /** + * Parses the inner constant of an interpolation plus its closing brace, appending the constant as a value part. + * The region of a folded constant can end anywhere inside the closing syntax, so the constant and the brace are + * matched against the file rather than the region. Returns the position after the brace. + */ + private fun parseInterpolationTail(start: Int, parts: MutableList): Int? { + var pos = parseInnerConstant(start, parts) ?: return null + while (pos < fileText.length && fileText[pos].isWhitespace()) pos++ + if (pos >= fileText.length || fileText[pos] != '}') return null + return pos + 1 + } + + /** + * Parses the constant expression of an interpolation and appends it as a single value part. Supported are the + * literals whose string rendering can be derived from the source: strings, characters, booleans, and integers. + * The rendering is verified against the folded value by the caller, so an unexpected rendering surfaces as an + * unsplittable constant rather than a wrong split. Returns the position after the constant, or null when the + * expression is not a supported literal. + */ + private fun parseInnerConstant(start: Int, parts: MutableList): Int? { + if (start >= fileText.length) return null + if (literalPrefix(start) != null) { + return parseInnerLiteral(start, parts) + } + if (fileText[start] == '\'') { + return parseCharLiteral(start, parts) + } + for (keyword in listOf("true", "false")) { + val end = start + keyword.length + if (fileText.startsWith(keyword, start) && (end >= fileText.length || !isIdentifierPart(fileText[end]))) { + parts.add(TemplateValue(start, end, keyword)) + return end + } + } + return parseIntegerLiteral(start, parts) + } + + /** Parses a character literal like `'c'` or `'\n'` and appends it as a value part. */ + private fun parseCharLiteral(start: Int, parts: MutableList): Int? { + var pos = start + 1 + if (pos >= fileText.length) return null + val text: String + if (fileText[pos] == '\\') { + val decoded = decodeEscape(pos, fileText.length) ?: return null + text = decoded.first + pos = decoded.second + } else { + text = fileText[pos].toString() + pos++ + } + if (pos >= fileText.length || fileText[pos] != '\'') return null + parts.add(TemplateValue(start, pos + 1, text)) + return pos + 1 + } + + /** Parses an integer literal, decimal, hexadecimal, or binary, with optional sign and suffixes. */ + private fun parseIntegerLiteral(start: Int, parts: MutableList): Int? { + var pos = start + var sign = "" + if (pos < fileText.length && fileText[pos] == '-') { + sign = "-" + pos++ + } + if (pos >= fileText.length || !fileText[pos].isDigit()) return null + val radix: Int + val digitsStart: Int + when { + fileText.startsWith("0x", pos) || fileText.startsWith("0X", pos) -> { + radix = 16 + digitsStart = pos + 2 + } + fileText.startsWith("0b", pos) || fileText.startsWith("0B", pos) -> { + radix = 2 + digitsStart = pos + 2 + } + else -> { + radix = 10 + digitsStart = pos + } + } + pos = digitsStart + val digits = StringBuilder() + while (pos < fileText.length && (Character.digit(fileText[pos], radix) >= 0 || fileText[pos] == '_')) { + if (fileText[pos] != '_') digits.append(fileText[pos]) + pos++ + } + if (digits.isEmpty()) return null + if (pos < fileText.length && (fileText[pos] == '.' || fileText[pos].lowercaseChar() in "ef")) { + // A floating-point literal; its rendering is not derived here. + return null + } + if (pos < fileText.length && (fileText[pos] == 'u' || fileText[pos] == 'U')) pos++ + if (pos < fileText.length && fileText[pos] == 'L') pos++ + parts.add(TemplateValue(start, pos, sign + java.math.BigInteger(digits.toString(), radix))) + return pos + } + + private fun isIdentifierPart(c: Char): Boolean = c.isLetterOrDigit() || c == '_' + + /** Parses one or more complete quoted literals joined by `+`, the shape of a folded operator chain. */ + private fun parseLiteralChain(start: Int, end: Int): List? { + val parts = mutableListOf() + var pos = start + while (true) { + val (kind, contentStart) = literalPrefix(pos) ?: return null + pos = scanContent(contentStart, end, kind, parts, stopAtClosingQuote = true) ?: return null + while (pos < end && fileText[pos].isWhitespace()) pos++ + if (pos == end) return parts + if (fileText[pos] != '+') return null + pos++ + while (pos < end && fileText[pos].isWhitespace()) pos++ + } + } + + /** + * Parses the string literal of an interpolation and appends it as a single value part. The literal is scanned + * against the whole file because a folded constant's region can end before the literal's closing quote. Returns + * the position after the closing quote, or null when the interpolated expression is not a string literal. + */ + private fun parseInnerLiteral(start: Int, parts: MutableList): Int? { + val (kind, contentStart) = literalPrefix(start) ?: return null + val inner = mutableListOf() + val afterContent = scanContent(contentStart, fileText.length, kind, inner, stopAtClosingQuote = true, allowMarkers = false) + ?: return null + parts.add(TemplateValue(start, afterContent, inner.joinToString("") { it.text })) + return afterContent + } + + /** + * Scans literal content, decoding escape sequences and splitting out `${"..."}` interpolations, and appends the + * resulting text and value parts to [parts]. Returns the position after the content: after the closing quotes + * when [stopAtClosingQuote] is set, [limit] otherwise. Returns null when the content cannot be a folded + * constant template, e.g. an interpolation of anything but a string literal. + */ + private fun scanContent( + start: Int, + limit: Int, + kind: LiteralKind, + parts: MutableList, + stopAtClosingQuote: Boolean, + allowMarkers: Boolean = true, + ): Int? { + val chunk = StringBuilder() + var chunkStart = start + var pos = start + + fun flushChunk(endOffset: Int) { + if (chunk.isNotEmpty()) { + parts.add(TemplateText(chunkStart, endOffset, chunk.toString())) + chunk.setLength(0) + } + } + + while (pos < limit) { + val c = fileText[pos] + if (c == '"' && stopAtClosingQuote) { + var runEnd = pos + while (runEnd < limit && fileText[runEnd] == '"') runEnd++ + val runLength = runEnd - pos + if (!kind.raw) { + flushChunk(pos) + return pos + 1 + } + if (runLength >= 3) { + // The final three quotes close a raw literal; preceding quotes in the run are content. + repeat(runLength - 3) { chunk.append('"') } + flushChunk(runEnd - 3) + return runEnd + } + repeat(runLength) { chunk.append('"') } + pos = runEnd + continue + } + if (c == '\\' && !kind.raw) { + val decoded = decodeEscape(pos, limit) ?: return null + chunk.append(decoded.first) + pos = decoded.second + continue + } + if (c == '$') { + var runEnd = pos + while (runEnd < limit && fileText[runEnd] == '$') runEnd++ + val dollars = runEnd - pos + val next = if (runEnd < limit) fileText[runEnd] else '' + if (dollars >= kind.dollars && (next == '{' || isIdentifierStart(next))) { + sawInterpolation = true + if (!allowMarkers) return null + if (next != '{') { + // Simple-name interpolation: the folded value cannot be recovered from the source. + return null + } + // Dollars beyond the marker's count are literal text before the marker. + repeat(dollars - kind.dollars) { chunk.append('$') } + flushChunk(pos + (dollars - kind.dollars)) + var innerPos = runEnd + 1 + while (innerPos < fileText.length && fileText[innerPos].isWhitespace()) innerPos++ + pos = parseInterpolationTail(innerPos, parts) ?: return null + chunkStart = pos + if (pos >= limit) { + // The region ends inside the interpolation's closing syntax. + return if (stopAtClosingQuote) null else limit + } + continue + } + repeat(dollars) { chunk.append('$') } + pos = runEnd + continue + } + chunk.append(c) + pos++ + } + if (stopAtClosingQuote) return null // Truncated literal: the closing quote lies beyond the region. + flushChunk(limit) + return limit + } + + /** Recognizes the dollars-and-quotes prefix of a string literal at [pos], e.g. `"`, `"""`, or `$$"`. */ + private fun literalPrefix(pos: Int): Pair? { + var p = pos + while (p < fileText.length && fileText[p] == '$') p++ + val dollars = p - pos + if (p >= fileText.length || fileText[p] != '"') return null + val raw = fileText.startsWith("\"\"\"", p) + return LiteralKind(maxOf(dollars, 1), raw) to p + if (raw) 3 else 1 + } + + /** The number of marker dollars when the characters directly before [start] are `$`-run plus `{`, or null. */ + private fun markerBehind(start: Int): Int? { + if (start < 2 || fileText[start - 1] != '{') return null + var p = start - 2 + while (p >= 0 && fileText[p] == '$') p-- + val dollars = start - 2 - p + return if (dollars >= 1) dollars else null + } + + /** The literal kind when [start] sits directly after an opening quote, or null. */ + private fun openingQuoteBehind(start: Int): LiteralKind? { + var p = start - 1 + while (p >= 0 && fileText[p] == '"') p-- + val quotes = start - 1 - p + if (quotes != 1 && quotes < 3) return null + var d = p + while (d >= 0 && fileText[d] == '$') d-- + return LiteralKind(maxOf(p - d, 1), raw = quotes >= 3) + } + + /** Decodes the escape sequence at [pos]. Returns the decoded text and the position after the sequence. */ + private fun decodeEscape(pos: Int, limit: Int): Pair? { + if (pos + 1 >= limit) return null + return when (fileText[pos + 1]) { + 't' -> "\t" to pos + 2 + 'b' -> "\b" to pos + 2 + 'n' -> "\n" to pos + 2 + 'r' -> "\r" to pos + 2 + '\'' -> "'" to pos + 2 + '"' -> "\"" to pos + 2 + '\\' -> "\\" to pos + 2 + '$' -> "$" to pos + 2 + 'u' -> { + if (pos + 6 > limit) return null + val code = fileText.substring(pos + 2, pos + 6).toIntOrNull(16) ?: return null + code.toChar().toString() to pos + 6 + } + else -> null + } + } + + private fun isIdentifierStart(c: Char): Boolean = c.isLetter() || c == '_' || c == '`' +} diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index c747b3fbc..f929559cc 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -4,24 +4,33 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageLocation +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.ir.IrElement import org.jetbrains.kotlin.ir.builders.irCall import org.jetbrains.kotlin.ir.builders.irConcat import org.jetbrains.kotlin.ir.builders.irGet import org.jetbrains.kotlin.ir.declarations.IrFile import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.expressions.IrBlock +import org.jetbrains.kotlin.ir.expressions.IrBlockBody import org.jetbrains.kotlin.ir.expressions.IrCall import org.jetbrains.kotlin.ir.expressions.IrConst import org.jetbrains.kotlin.ir.expressions.IrExpression import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression -import org.jetbrains.kotlin.ir.expressions.IrBlockBody +import org.jetbrains.kotlin.ir.expressions.IrReturn import org.jetbrains.kotlin.ir.expressions.IrStringConcatenation +import org.jetbrains.kotlin.ir.expressions.IrTypeOperatorCall +import org.jetbrains.kotlin.ir.expressions.IrWhen import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI import org.jetbrains.kotlin.ir.types.IrType import org.jetbrains.kotlin.ir.types.classFqName import org.jetbrains.kotlin.ir.util.functions import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name @@ -40,6 +49,12 @@ import org.jetbrains.kotlin.name.Name * inside an interpolation yields a value rather than SQL, so its own interpolations and concatenations are left to * Kotlin: `"... LIKE ${"%" + name + "%"}"` interpolates a single string. * + * Constant interpolations like `${"value"}` are folded into the surrounding template text by the Kotlin compiler + * before this transformer runs. The transformer parses the source text behind such folded constants to split them + * back into SQL text and t()-wrapped values, and verifies the reassembled text against the constant's actual value. + * A folded constant that cannot be split provably-correctly is reported as a compiler error, so an interpolation + * can never silently remain SQL text. + * * Example transformation: * * Source: @@ -54,6 +69,7 @@ import org.jetbrains.kotlin.name.Name */ class StormTemplateIrTransformer( private val pluginContext: IrPluginContext, + private val messageCollector: MessageCollector = MessageCollector.NONE, ) : IrElementTransformerVoid() { companion object { @@ -81,17 +97,26 @@ class StormTemplateIrTransformer( /** Cached symbol for `TemplateContext.autoInterpolation()`. */ private var autoInterpolationSymbol: IrSimpleFunction? = null - /** Source text of the current file, cached for splitting merged constants. */ - private var currentSourceText: String? = null + /** The file being visited, for diagnostic locations. */ + private var currentFile: IrFile? = null + + /** Parser over the current file's source text, or null when the source cannot be read. */ + private var currentParser: TemplateSourceParser? = null + + /** Limits the unreadable-source warning to one per file. */ + private var unverifiableReported: Boolean = false override fun visitFile(declaration: IrFile): IrFile { - currentSourceText = try { - java.io.File(declaration.fileEntry.name).readText() + currentFile = declaration + currentParser = try { + TemplateSourceParser(java.io.File(declaration.fileEntry.name).readText()) } catch (_: Exception) { null } + unverifiableReported = false val result = super.visitFile(declaration) - currentSourceText = null + currentFile = null + currentParser = null return result } @@ -116,6 +141,10 @@ class StormTemplateIrTransformer( autoInterpolationSymbol = resolveAutoInterpolationFunction() } val result = super.visitFunctionExpression(expression) + // Recover templates that folded into a single constant; they have no concatenation for the visitor above. + tFunctionSymbol?.let { tFunction -> + function.body?.transformChildrenVoid(ResultConstantRewriter(function, extensionReceiver, tFunction)) + } // Inject autoInterpolation() call at the start of the lambda body to signal that the plugin is active. val autoInterpolation = autoInterpolationSymbol if (autoInterpolation != null) { @@ -155,6 +184,71 @@ class StormTemplateIrTransformer( return processConcatenation(concatenation, operatorConcatenation = true) } + /** + * Recovers templates that folded into a single string constant: a lambda whose interpolations are all constant + * yields a plain constant result with no concatenation for the visitor to rewrite. The rewrite is limited to + * the lambda's result positions, i.e. the returned expression and the expressions it derives from: branch + * results and call receivers such as a `trimIndent()` chain. Constants elsewhere in the lambda, e.g. messages + * inside nested lambdas, are not template text and keep their folded value. + */ + private inner class ResultConstantRewriter( + private val function: org.jetbrains.kotlin.ir.declarations.IrFunction, + private val receiver: IrValueParameter, + private val tFunction: IrSimpleFunction, + ) : IrElementTransformerVoid() { + + override fun visitReturn(expression: IrReturn): IrExpression { + val result = super.visitReturn(expression) + if (result is IrReturn && result.returnTargetSymbol == function.symbol) { + result.value = rewriteResultExpression(result.value) + } + return result + } + + private fun rewriteResultExpression(expression: IrExpression): IrExpression { + when (expression) { + is IrConst -> if (expression.value is String) { + return replaceFoldedConstant(expression, receiver, tFunction) + } + is IrWhen -> expression.branches.forEach { branch -> + branch.result = rewriteResultExpression(branch.result) + } + is IrBlock -> { + val statements = expression.statements + val last = statements.lastOrNull() + if (last is IrExpression) { + statements[statements.size - 1] = rewriteResultExpression(last) + } + } + is IrTypeOperatorCall -> expression.argument = rewriteResultExpression(expression.argument) + is IrCall -> rewriteCallReceivers(expression) + else -> {} + } + return expression + } + + private fun rewriteCallReceivers(call: IrCall) { + call.dispatchReceiver?.let { call.dispatchReceiver = rewriteResultExpression(it) } + call.extensionReceiver?.let { call.extensionReceiver = rewriteResultExpression(it) } + } + } + + /** Splits a folded string constant into a concatenation of its template parts, or returns it unchanged. */ + private fun replaceFoldedConstant( + irConst: IrConst, + receiver: IrValueParameter, + tFunction: IrSimpleFunction, + ): IrExpression { + val parts = classifyStringConst(irConst, enclosingKind = null, ConstPosition.STANDALONE, receiver, tFunction) + if (parts.size == 1 && parts[0] === irConst) { + return irConst + } + val builder = DeclarationIrBuilder(pluginContext, tFunction.symbol, irConst.startOffset, irConst.endOffset) + val concatenation = builder.irConcat() + concatenation.arguments.addAll(parts) + return concatenation + } + /** * Applies the template rules to the arguments of [expression]: literal text stays a fragment and every other * argument is wrapped in a `t()` call. @@ -170,17 +264,16 @@ class StormTemplateIrTransformer( ): IrExpression { val receiver = templateContextReceiver ?: return expression val tFunction = tFunctionSymbol ?: return expression + // The literal's own prefix determines how markers and escapes in its folded constants are interpreted. + val enclosingKind = if (operatorConcatenation) null else currentParser?.literalKindAt(expression.startOffset) // Recursively transform each argument first, so that nested TemplateBuilder lambdas (e.g., inside subquery - // calls) are processed before we wrap the argument in t(). + // calls) are processed before we wrap the argument in t(). Constants have no children to transform and are + // classified below instead, keeping their handling out of visitConst's standalone path. val newArguments = expression.arguments.flatMap { argument -> - val transformed = transformInPosition(argument, operatorConcatenation) + val transformed = if (argument is IrConst) argument else transformInPosition(argument, operatorConcatenation) when { - transformed is IrConst && isFragment(transformed) -> listOf(transformed) - transformed is IrConst && hasMergedConstant(transformed) -> - splitMergedConstant(transformed, receiver, tFunction) - // A string literal operand of a `+` chain is SQL text, like the literal part of a string template. - // Any other constant is interpolated, so that `+ 42` and `${42}` produce the same bind value. - transformed is IrConst && operatorConcatenation && transformed.value is String -> listOf(transformed) + transformed is IrConst && transformed.value is String -> + classifyStringConst(transformed, enclosingKind, ConstPosition.of(operatorConcatenation), receiver, tFunction) transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) @@ -222,133 +315,82 @@ class StormTemplateIrTransformer( } /** - * Checks whether an [IrConst] is a string template fragment (literal SQL text) as opposed to an interpolated - * constant expression like `${"value"}`. + * Applies the template rules to a string constant in text position and returns the expressions that replace it. * - * Fragment [IrConst] entries have a source offset span that matches the text length, because they represent literal - * text from the template. Interpolated constant expressions have a larger offset span, since their source range - * includes the surrounding syntax (e.g., quotes for string literals). + * A constant whose source text spells out its value verbatim is literal template text and stays a fragment. Any + * other constant is handed to [TemplateSourceParser]: when its source region parses as template syntax and the + * reassembled text equals the constant's value, the folded `${"..."}` interpolations become t()-wrapped values + * and the rest stays text. A region that contains interpolation syntax but cannot be split that way is reported + * as a compiler error; regions without interpolation syntax (plain literals, `+` chain operands, escaped text) + * are left unchanged. */ - private fun isFragment(irConst: IrConst): Boolean { - val value = irConst.value - if (value !is String) return false - return irConst.endOffset - irConst.startOffset == value.length - } - - /** - * Checks whether an [IrConst] contains a merged constant expression. This happens when the Kotlin compiler folds - * an inline constant expression like `${"value"}` with adjacent literal template text into a single [IrConst]. - * The merged entry has a source offset span larger than the text length, because the source range includes the - * `${"..."}` syntax in addition to the literal text. - */ - private fun hasMergedConstant(irConst: IrConst): Boolean { - val value = irConst.value - if (value !is String) return false - return irConst.endOffset - irConst.startOffset > value.length - } - - /** - * Splits a merged [IrConst] (containing both literal template text and folded inline constant expressions) into - * separate fragment and wrapped expression entries. - * - * This method reads the source file to find `${"..."}` patterns within the [IrConst]'s source range, then splits - * the merged text accordingly. Each inline constant expression is wrapped in a `t()` call, while literal text - * fragments remain as plain [IrConst] entries. - * - * If the source file cannot be read or the parsing fails (e.g., due to escape sequences in the constant), the - * original [IrConst] is returned unchanged to avoid incorrect transformations. - */ - private fun splitMergedConstant( + private fun classifyStringConst( irConst: IrConst, + enclosingKind: LiteralKind?, + position: ConstPosition, receiver: IrValueParameter, tFunction: IrSimpleFunction, ): List { - val sourceText = currentSourceText ?: return listOf(irConst) - val mergedText = irConst.value as String - val sourceStart = irConst.startOffset - val sourceEnd = irConst.endOffset - if (sourceStart < 0 || sourceEnd > sourceText.length) return listOf(irConst) - val source = sourceText.substring(sourceStart, sourceEnd) - // Find all ${"..."} patterns in the source and split the merged text. - val result = mutableListOf() - var textPosition = 0 - var sourcePosition = 0 - while (sourcePosition < source.length) { - val expressionStart = source.indexOf("\${\"", sourcePosition) - if (expressionStart == -1) break - // Add the fragment before the expression. - val fragmentSourceLength = expressionStart - sourcePosition - if (fragmentSourceLength > 0) { - val fragmentText = mergedText.substring(textPosition, textPosition + fragmentSourceLength) - val fragmentSource = source.substring(sourcePosition, expressionStart) - if (fragmentSource != fragmentText) { - // Fragment contains escape sequences; cannot reliably split. - return listOf(irConst) - } - result.add(createStringConst( - sourceStart + sourcePosition, - sourceStart + expressionStart, - irConst.type, - fragmentText, - )) - textPosition += fragmentSourceLength - } - // Parse the string literal inside ${"..."}. - val contentStart = expressionStart + 3 // position after ${" - val closingQuote = findClosingQuote(source, contentStart) - if (closingQuote == -1) return listOf(irConst) // Malformed; leave unchanged. - val expressionSourceContent = source.substring(contentStart, closingQuote) - if (expressionSourceContent.contains('\\')) { - // Expression contains escape sequences; cannot reliably determine the runtime value. - return listOf(irConst) + val value = irConst.value as String + val parser = currentParser + val start = irConst.startOffset + val end = irConst.endOffset + if (parser == null || start < 0 || end < start || !parser.inBounds(end)) { + // Without source text the constant cannot be verified. A concatenation argument whose source span does + // not match its value length either contains folded constants or escape sequences; neither can be told + // apart nor checked, which is worth one warning per file. Standalone constants are mostly ordinary + // literals whose span includes the quotes, so they stay silent. + if (position == ConstPosition.TEMPLATE_ARGUMENT && parser == null && end - start != value.length) { + reportUnverifiableTemplate(irConst) } - val expressionValueLength = expressionSourceContent.length - if (textPosition + expressionValueLength > mergedText.length) return listOf(irConst) - val expressionValue = mergedText.substring(textPosition, textPosition + expressionValueLength) - if (expressionValue != expressionSourceContent) { - // Mismatch between source and merged text; cannot reliably split. + return listOf(unverifiedConst(irConst, position, receiver, tFunction)) + } + if (parser.matchesSource(start, end, value)) { + // Literal template text. + return listOf(irConst) + } + val parts = parser.parse(start, end, enclosingKind) + if (parts != null && parts.joinToString("") { it.text } == value) { + if (parts.none { it is TemplateValue }) { + // Literal text whose source spelling differs from its value: escape sequences or quoted operands. return listOf(irConst) } - // Wrap the expression value in t(). - val expressionConst = createStringConst( - sourceStart + expressionStart + 2, // position of opening " - sourceStart + closingQuote + 1, // position after closing " - irConst.type, - expressionValue, - ) - result.add(wrapInT(expressionConst, receiver, tFunction)) - textPosition += expressionValueLength - sourcePosition = closingQuote + 2 // skip past "} - } - // Add remaining fragment after the last expression. - if (textPosition < mergedText.length) { - val remainingFragment = mergedText.substring(textPosition) - val remainingSource = source.substring(sourcePosition) - if (remainingSource != remainingFragment) { - // Fragment contains escape sequences; cannot reliably split. - return listOf(irConst) + return parts.map { part -> + val partConst = createStringConst(part.startOffset, part.endOffset, irConst.type, part.text) + if (part is TemplateValue) wrapInT(partConst, receiver, tFunction) else partConst } - result.add(createStringConst( - sourceStart + sourcePosition, - sourceEnd, - irConst.type, - remainingFragment, - )) - } - return if (result.isEmpty()) listOf(irConst) else result - } - - /** Finds the position of the closing quote (`"`) in a string literal, handling escaped quotes. */ - private fun findClosingQuote(source: String, fromIndex: Int): Int { - var i = fromIndex - while (i < source.length) { - when (source[i]) { - '"' -> return i - '\\' -> i++ // Skip escaped character. + } + if (parser.sawInterpolation) { + if (parser.isInterpolationFold(start)) { + // The compiler folded a constant expression in place of a single interpolation, e.g. a const val + // reference. Such folds never merge with the surrounding template text, so the constant's value is + // the interpolation's value and binds like any other interpolated argument. + return listOf(wrapInT(irConst, receiver, tFunction)) } - i++ + reportUnsplittableConstant(irConst, start, end) + return listOf(irConst) } - return -1 + return listOf(unverifiedConst(irConst, position, receiver, tFunction)) + } + + /** + * The fallback for a string constant the source cannot vouch for. A constant whose source span is shorter than + * its value cannot be a fragment of the template's literal text; as the interpolated argument of a string + * template it yields a bind value, matching the treatment of non-constant arguments. In every other position + * the constant stays text: chain operands are literal by the template rules, and standalone constants are + * ordinary literals whose span includes the quotes. + */ + private fun unverifiedConst( + irConst: IrConst, + position: ConstPosition, + receiver: IrValueParameter, + tFunction: IrSimpleFunction, + ): IrExpression { + val value = irConst.value as String + if (position == ConstPosition.TEMPLATE_ARGUMENT && irConst.endOffset - irConst.startOffset < value.length) { + return wrapInT(irConst, receiver, tFunction) + } + return irConst } /** Creates a new string [IrConst] with the given offsets and value. */ @@ -400,6 +442,46 @@ class StormTemplateIrTransformer( body.statements.add(0, call) } + /** Reports a compiler error for a folded constant the plugin cannot split into template text and values. */ + private fun reportUnsplittableConstant(irConst: IrConst, start: Int, end: Int) { + val snippet = currentParser?.snippet(start, end) ?: irConst.value.toString() + report( + CompilerMessageSeverity.ERROR, + "Storm compiler plugin cannot determine which parts of this SQL template are text and which are " + + "values: the Kotlin compiler folded a constant expression into the surrounding template text " + + "($snippet). Interpolate the constant with an explicit t() or interpolate() call, or inline it " + + "as a plain string literal.", + irConst, + ) + } + + /** Reports, once per file, that folded constants cannot be verified because the source is unreadable. */ + private fun reportUnverifiableTemplate(irConst: IrConst) { + if (unverifiableReported) return + unverifiableReported = true + report( + CompilerMessageSeverity.WARNING, + "Storm compiler plugin cannot read the source of ${currentFile?.fileEntry?.name} to verify constant " + + "expressions folded into SQL templates; the folded constants are left as template text.", + irConst, + ) + } + + private fun report(severity: CompilerMessageSeverity, message: String, element: IrElement) { + val fileEntry = currentFile?.fileEntry + val location = if (fileEntry != null && element.startOffset >= 0) { + CompilerMessageLocation.create( + fileEntry.name, + fileEntry.getLineNumber(element.startOffset) + 1, + fileEntry.getColumnNumber(element.startOffset) + 1, + null, + ) + } else { + null + } + messageCollector.report(severity, message, location) + } + /** Resolves the `TemplateContext.t(Any?): String` function symbol. */ private fun resolveTFunction(): IrSimpleFunction? { val templateContextClass = pluginContext.referenceClass(TEMPLATE_CONTEXT_CLASS_ID) ?: return null @@ -414,3 +496,412 @@ class StormTemplateIrTransformer( .firstOrNull { it.name.asString() == "autoInterpolation" && it.valueParameters.isEmpty() } } } + +/** Where a string constant sits relative to the template being processed. */ +internal enum class ConstPosition { + /** An argument of a string template literal. */ + TEMPLATE_ARGUMENT, + + /** An operand of a `+` chain. */ + CHAIN_OPERAND, + + /** A constant that is not part of any concatenation. */ + STANDALONE; + + companion object { + fun of(operatorConcatenation: Boolean): ConstPosition = + if (operatorConcatenation) CHAIN_OPERAND else TEMPLATE_ARGUMENT + } +} + +/** + * A piece of a parsed template region. Offsets are absolute positions in the source file. + */ +internal sealed class TemplatePart { + abstract val startOffset: Int + abstract val endOffset: Int + abstract val text: String +} + +/** Literal SQL text. */ +internal class TemplateText( + override val startOffset: Int, + override val endOffset: Int, + override val text: String, +) : TemplatePart() + +/** A folded constant expression that yields a bind value. */ +internal class TemplateValue( + override val startOffset: Int, + override val endOffset: Int, + override val text: String, +) : TemplatePart() + +/** + * Interpolation syntax of a string literal: the number of dollar signs in an interpolation marker and whether the + * literal is raw (triple-quoted, no escape processing). + */ +internal data class LiteralKind(val dollars: Int, val raw: Boolean) + +/** + * Parses the source text behind a folded string constant back into the template pieces the constant was folded + * from: literal text and `${"..."}` constant expressions. + * + * Parsing is driven purely by the source text; the caller verifies the reassembled text against the constant's + * actual value, so a parse that would change the meaning of the template cannot go undetected. The parser handles + * the shapes the compiler produces: bare template content, content that starts at the inner literal of an + * interpolation whose `${` marker lies before the region, and complete quoted literals joined by `+`. Escape + * sequences are decoded in regular literals and taken verbatim in raw literals, and multi-dollar literals only + * treat runs of at least the marker's dollar count as interpolations. + */ +internal class TemplateSourceParser(private val fileText: String) { + + /** + * Set when the most recent [parse] encountered interpolation syntax, even if parsing subsequently failed. + * Distinguishes template-shaped source, whose failures must be loud, from plain constants. + */ + var sawInterpolation: Boolean = false + private set + + fun inBounds(offset: Int): Boolean = offset in 0..fileText.length + + /** Checks whether the source region [start] until [end] spells out [value] verbatim. */ + fun matchesSource(start: Int, end: Int, value: String): Boolean = + end - start == value.length && fileText.regionMatches(start, value, 0, value.length) + + /** A condensed, quoted rendering of the source region for diagnostics. */ + fun snippet(start: Int, end: Int): String { + val region = fileText.substring(start, end).replace("\n", "\\n") + return if (region.length <= 60) "'$region'" else "'${region.take(57)}...'" + } + + /** The literal kind of the string literal that starts at [offset], or null when the offset does not sit on one. */ + fun literalKindAt(offset: Int): LiteralKind? { + if (offset < 0 || offset >= fileText.length) return null + return literalPrefix(offset)?.first + } + + /** + * Checks whether the region at [start] is the folded form of a single interpolation: its source sits directly + * inside interpolation syntax, `$name` or `${name}`, rather than starting at a string literal. The compiler + * folds such constants in place of the interpolation without merging the surrounding template text, so the + * constant's value is the interpolation's value. Merged constants always start at literal syntax instead. + */ + fun isInterpolationFold(start: Int): Boolean { + if (literalPrefix(start) != null) return false + return markerBehind(start) != null || (start > 0 && fileText[start - 1] == '$') + } + + /** + * Parses the region [start] until [end] into template parts, or returns null when the region cannot be related + * to template syntax. [enclosingKind] is the kind of the string literal the region belongs to, when known. + */ + fun parse(start: Int, end: Int, enclosingKind: LiteralKind?): List? { + sawInterpolation = false + if (start < 0 || end < start || end > fileText.length) return null + markerBehind(start)?.let { dollars -> + // The region starts at the inner literal of an interpolation whose marker lies before it. + sawInterpolation = true + if (enclosingKind != null) { + return parseContinuation(start, end, LiteralKind(dollars, enclosingKind.raw)) + } + // The enclosing literal's kind is unknown; the value verification in the caller picks the attempt + // that reproduces the constant. + return parseContinuation(start, end, LiteralKind(dollars, raw = false)) + ?: parseContinuation(start, end, LiteralKind(dollars, raw = true)) + } + if (start > 0 && fileText[start - 1] == '$') { + // The region starts at the identifier of a simple-name interpolation like $CONST: the folded value + // cannot be recovered from the source. + sawInterpolation = true + return null + } + if (literalPrefix(start) != null) { + return parseLiteralChain(start, end) + } + val kind = enclosingKind ?: openingQuoteBehind(start) ?: return null + val parts = mutableListOf() + val consumed = scanContent(start, end, kind, parts, stopAtClosingQuote = false) ?: return null + if (consumed != end) return null + return parts + } + + /** Parses a region that starts inside an interpolation: the inner literal, its closing brace, then content. */ + private fun parseContinuation(start: Int, end: Int, kind: LiteralKind): List? { + val parts = mutableListOf() + val pos = parseInterpolationTail(start, parts) ?: return null + if (pos >= end) return parts // The region ends inside the interpolation's closing syntax. + val consumed = scanContent(pos, end, kind, parts, stopAtClosingQuote = false) ?: return null + if (consumed != end) return null + return parts + } + + /** + * Parses the inner constant of an interpolation plus its closing brace, appending the constant as a value part. + * The region of a folded constant can end anywhere inside the closing syntax, so the constant and the brace are + * matched against the file rather than the region. Returns the position after the brace. + */ + private fun parseInterpolationTail(start: Int, parts: MutableList): Int? { + var pos = parseInnerConstant(start, parts) ?: return null + while (pos < fileText.length && fileText[pos].isWhitespace()) pos++ + if (pos >= fileText.length || fileText[pos] != '}') return null + return pos + 1 + } + + /** + * Parses the constant expression of an interpolation and appends it as a single value part. Supported are the + * literals whose string rendering can be derived from the source: strings, characters, booleans, and integers. + * The rendering is verified against the folded value by the caller, so an unexpected rendering surfaces as an + * unsplittable constant rather than a wrong split. Returns the position after the constant, or null when the + * expression is not a supported literal. + */ + private fun parseInnerConstant(start: Int, parts: MutableList): Int? { + if (start >= fileText.length) return null + if (literalPrefix(start) != null) { + return parseInnerLiteral(start, parts) + } + if (fileText[start] == '\'') { + return parseCharLiteral(start, parts) + } + for (keyword in listOf("true", "false")) { + val end = start + keyword.length + if (fileText.startsWith(keyword, start) && (end >= fileText.length || !isIdentifierPart(fileText[end]))) { + parts.add(TemplateValue(start, end, keyword)) + return end + } + } + return parseIntegerLiteral(start, parts) + } + + /** Parses a character literal like `'c'` or `'\n'` and appends it as a value part. */ + private fun parseCharLiteral(start: Int, parts: MutableList): Int? { + var pos = start + 1 + if (pos >= fileText.length) return null + val text: String + if (fileText[pos] == '\\') { + val decoded = decodeEscape(pos, fileText.length) ?: return null + text = decoded.first + pos = decoded.second + } else { + text = fileText[pos].toString() + pos++ + } + if (pos >= fileText.length || fileText[pos] != '\'') return null + parts.add(TemplateValue(start, pos + 1, text)) + return pos + 1 + } + + /** Parses an integer literal, decimal, hexadecimal, or binary, with optional sign and suffixes. */ + private fun parseIntegerLiteral(start: Int, parts: MutableList): Int? { + var pos = start + var sign = "" + if (pos < fileText.length && fileText[pos] == '-') { + sign = "-" + pos++ + } + if (pos >= fileText.length || !fileText[pos].isDigit()) return null + val radix: Int + val digitsStart: Int + when { + fileText.startsWith("0x", pos) || fileText.startsWith("0X", pos) -> { + radix = 16 + digitsStart = pos + 2 + } + fileText.startsWith("0b", pos) || fileText.startsWith("0B", pos) -> { + radix = 2 + digitsStart = pos + 2 + } + else -> { + radix = 10 + digitsStart = pos + } + } + pos = digitsStart + val digits = StringBuilder() + while (pos < fileText.length && (Character.digit(fileText[pos], radix) >= 0 || fileText[pos] == '_')) { + if (fileText[pos] != '_') digits.append(fileText[pos]) + pos++ + } + if (digits.isEmpty()) return null + if (pos < fileText.length && (fileText[pos] == '.' || fileText[pos].lowercaseChar() in "ef")) { + // A floating-point literal; its rendering is not derived here. + return null + } + if (pos < fileText.length && (fileText[pos] == 'u' || fileText[pos] == 'U')) pos++ + if (pos < fileText.length && fileText[pos] == 'L') pos++ + parts.add(TemplateValue(start, pos, sign + java.math.BigInteger(digits.toString(), radix))) + return pos + } + + private fun isIdentifierPart(c: Char): Boolean = c.isLetterOrDigit() || c == '_' + + /** Parses one or more complete quoted literals joined by `+`, the shape of a folded operator chain. */ + private fun parseLiteralChain(start: Int, end: Int): List? { + val parts = mutableListOf() + var pos = start + while (true) { + val (kind, contentStart) = literalPrefix(pos) ?: return null + pos = scanContent(contentStart, end, kind, parts, stopAtClosingQuote = true) ?: return null + while (pos < end && fileText[pos].isWhitespace()) pos++ + if (pos == end) return parts + if (fileText[pos] != '+') return null + pos++ + while (pos < end && fileText[pos].isWhitespace()) pos++ + } + } + + /** + * Parses the string literal of an interpolation and appends it as a single value part. The literal is scanned + * against the whole file because a folded constant's region can end before the literal's closing quote. Returns + * the position after the closing quote, or null when the interpolated expression is not a string literal. + */ + private fun parseInnerLiteral(start: Int, parts: MutableList): Int? { + val (kind, contentStart) = literalPrefix(start) ?: return null + val inner = mutableListOf() + val afterContent = scanContent(contentStart, fileText.length, kind, inner, stopAtClosingQuote = true, allowMarkers = false) + ?: return null + parts.add(TemplateValue(start, afterContent, inner.joinToString("") { it.text })) + return afterContent + } + + /** + * Scans literal content, decoding escape sequences and splitting out `${"..."}` interpolations, and appends the + * resulting text and value parts to [parts]. Returns the position after the content: after the closing quotes + * when [stopAtClosingQuote] is set, [limit] otherwise. Returns null when the content cannot be a folded + * constant template, e.g. an interpolation of anything but a string literal. + */ + private fun scanContent( + start: Int, + limit: Int, + kind: LiteralKind, + parts: MutableList, + stopAtClosingQuote: Boolean, + allowMarkers: Boolean = true, + ): Int? { + val chunk = StringBuilder() + var chunkStart = start + var pos = start + + fun flushChunk(endOffset: Int) { + if (chunk.isNotEmpty()) { + parts.add(TemplateText(chunkStart, endOffset, chunk.toString())) + chunk.setLength(0) + } + } + + while (pos < limit) { + val c = fileText[pos] + if (c == '"' && stopAtClosingQuote) { + var runEnd = pos + while (runEnd < limit && fileText[runEnd] == '"') runEnd++ + val runLength = runEnd - pos + if (!kind.raw) { + flushChunk(pos) + return pos + 1 + } + if (runLength >= 3) { + // The final three quotes close a raw literal; preceding quotes in the run are content. + repeat(runLength - 3) { chunk.append('"') } + flushChunk(runEnd - 3) + return runEnd + } + repeat(runLength) { chunk.append('"') } + pos = runEnd + continue + } + if (c == '\\' && !kind.raw) { + val decoded = decodeEscape(pos, limit) ?: return null + chunk.append(decoded.first) + pos = decoded.second + continue + } + if (c == '$') { + var runEnd = pos + while (runEnd < limit && fileText[runEnd] == '$') runEnd++ + val dollars = runEnd - pos + val next = if (runEnd < limit) fileText[runEnd] else '' + if (dollars >= kind.dollars && (next == '{' || isIdentifierStart(next))) { + sawInterpolation = true + if (!allowMarkers) return null + if (next != '{') { + // Simple-name interpolation: the folded value cannot be recovered from the source. + return null + } + // Dollars beyond the marker's count are literal text before the marker. + repeat(dollars - kind.dollars) { chunk.append('$') } + flushChunk(pos + (dollars - kind.dollars)) + var innerPos = runEnd + 1 + while (innerPos < fileText.length && fileText[innerPos].isWhitespace()) innerPos++ + pos = parseInterpolationTail(innerPos, parts) ?: return null + chunkStart = pos + if (pos >= limit) { + // The region ends inside the interpolation's closing syntax. + return if (stopAtClosingQuote) null else limit + } + continue + } + repeat(dollars) { chunk.append('$') } + pos = runEnd + continue + } + chunk.append(c) + pos++ + } + if (stopAtClosingQuote) return null // Truncated literal: the closing quote lies beyond the region. + flushChunk(limit) + return limit + } + + /** Recognizes the dollars-and-quotes prefix of a string literal at [pos], e.g. `"`, `"""`, or `$$"`. */ + private fun literalPrefix(pos: Int): Pair? { + var p = pos + while (p < fileText.length && fileText[p] == '$') p++ + val dollars = p - pos + if (p >= fileText.length || fileText[p] != '"') return null + val raw = fileText.startsWith("\"\"\"", p) + return LiteralKind(maxOf(dollars, 1), raw) to p + if (raw) 3 else 1 + } + + /** The number of marker dollars when the characters directly before [start] are `$`-run plus `{`, or null. */ + private fun markerBehind(start: Int): Int? { + if (start < 2 || fileText[start - 1] != '{') return null + var p = start - 2 + while (p >= 0 && fileText[p] == '$') p-- + val dollars = start - 2 - p + return if (dollars >= 1) dollars else null + } + + /** The literal kind when [start] sits directly after an opening quote, or null. */ + private fun openingQuoteBehind(start: Int): LiteralKind? { + var p = start - 1 + while (p >= 0 && fileText[p] == '"') p-- + val quotes = start - 1 - p + if (quotes != 1 && quotes < 3) return null + var d = p + while (d >= 0 && fileText[d] == '$') d-- + return LiteralKind(maxOf(p - d, 1), raw = quotes >= 3) + } + + /** Decodes the escape sequence at [pos]. Returns the decoded text and the position after the sequence. */ + private fun decodeEscape(pos: Int, limit: Int): Pair? { + if (pos + 1 >= limit) return null + return when (fileText[pos + 1]) { + 't' -> "\t" to pos + 2 + 'b' -> "\b" to pos + 2 + 'n' -> "\n" to pos + 2 + 'r' -> "\r" to pos + 2 + '\'' -> "'" to pos + 2 + '"' -> "\"" to pos + 2 + '\\' -> "\\" to pos + 2 + '$' -> "$" to pos + 2 + 'u' -> { + if (pos + 6 > limit) return null + val code = fileText.substring(pos + 2, pos + 6).toIntOrNull(16) ?: return null + code.toChar().toString() to pos + 6 + } + else -> null + } + } + + private fun isIdentifierStart(c: Char): Boolean = c.isLetter() || c == '_' || c == '`' +} diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index 02aa70f76..fb333af42 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -4,6 +4,10 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageLocation +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.ir.IrElement import org.jetbrains.kotlin.ir.builders.irCall import org.jetbrains.kotlin.ir.builders.irConcat import org.jetbrains.kotlin.ir.builders.irGet @@ -11,13 +15,16 @@ import org.jetbrains.kotlin.ir.declarations.IrFile import org.jetbrains.kotlin.ir.declarations.IrParameterKind import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.expressions.IrBlock +import org.jetbrains.kotlin.ir.expressions.IrBlockBody import org.jetbrains.kotlin.ir.expressions.IrCall import org.jetbrains.kotlin.ir.expressions.IrConst -import org.jetbrains.kotlin.ir.expressions.IrConstKind import org.jetbrains.kotlin.ir.expressions.IrExpression import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression -import org.jetbrains.kotlin.ir.expressions.IrBlockBody +import org.jetbrains.kotlin.ir.expressions.IrReturn import org.jetbrains.kotlin.ir.expressions.IrStringConcatenation +import org.jetbrains.kotlin.ir.expressions.IrTypeOperatorCall +import org.jetbrains.kotlin.ir.expressions.IrWhen import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI import org.jetbrains.kotlin.ir.types.IrType @@ -25,6 +32,7 @@ import org.jetbrains.kotlin.ir.types.classOrNull import org.jetbrains.kotlin.ir.util.fqNameWhenAvailable import org.jetbrains.kotlin.ir.util.functions import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name @@ -43,6 +51,12 @@ import org.jetbrains.kotlin.name.Name * inside an interpolation yields a value rather than SQL, so its own interpolations and concatenations are left to * Kotlin: `"... LIKE ${"%" + name + "%"}"` interpolates a single string. * + * Constant interpolations like `${"value"}` are folded into the surrounding template text by the Kotlin compiler + * before this transformer runs. The transformer parses the source text behind such folded constants to split them + * back into SQL text and t()-wrapped values, and verifies the reassembled text against the constant's actual value. + * A folded constant that cannot be split provably-correctly is reported as a compiler error, so an interpolation + * can never silently remain SQL text. + * * Example transformation: * * Source: @@ -57,6 +71,7 @@ import org.jetbrains.kotlin.name.Name */ class StormTemplateIrTransformer( private val pluginContext: IrPluginContext, + private val messageCollector: MessageCollector = MessageCollector.NONE, ) : IrElementTransformerVoid() { companion object { @@ -84,17 +99,26 @@ class StormTemplateIrTransformer( /** Cached symbol for `TemplateContext.autoInterpolation()`. */ private var autoInterpolationSymbol: IrSimpleFunction? = null - /** Source text of the current file, cached for splitting merged constants. */ - private var currentSourceText: String? = null + /** The file being visited, for diagnostic locations. */ + private var currentFile: IrFile? = null + + /** Parser over the current file's source text, or null when the source cannot be read. */ + private var currentParser: TemplateSourceParser? = null + + /** Limits the unreadable-source warning to one per file. */ + private var unverifiableReported: Boolean = false override fun visitFile(declaration: IrFile): IrFile { - currentSourceText = try { - java.io.File(declaration.fileEntry.name).readText() + currentFile = declaration + currentParser = try { + TemplateSourceParser(java.io.File(declaration.fileEntry.name).readText()) } catch (_: Exception) { null } + unverifiableReported = false val result = super.visitFile(declaration) - currentSourceText = null + currentFile = null + currentParser = null return result } @@ -121,6 +145,10 @@ class StormTemplateIrTransformer( autoInterpolationSymbol = resolveAutoInterpolationFunction() } val result = super.visitFunctionExpression(expression) + // Recover templates that folded into a single constant; they have no concatenation for the visitor above. + tFunctionSymbol?.let { tFunction -> + function.body?.transformChildrenVoid(ResultConstantRewriter(function, extensionReceiver, tFunction)) + } // Inject autoInterpolation() call at the start of the lambda body to signal that the plugin is active. val autoInterpolation = autoInterpolationSymbol if (autoInterpolation != null) { @@ -160,6 +188,76 @@ class StormTemplateIrTransformer( return processConcatenation(concatenation, operatorConcatenation = true) } + /** + * Recovers templates that folded into a single string constant: a lambda whose interpolations are all constant + * yields a plain constant result with no concatenation for the visitor to rewrite. The rewrite is limited to + * the lambda's result positions, i.e. the returned expression and the expressions it derives from: branch + * results and call receivers such as a `trimIndent()` chain. Constants elsewhere in the lambda, e.g. messages + * inside nested lambdas, are not template text and keep their folded value. + */ + private inner class ResultConstantRewriter( + private val function: org.jetbrains.kotlin.ir.declarations.IrFunction, + private val receiver: IrValueParameter, + private val tFunction: IrSimpleFunction, + ) : IrElementTransformerVoid() { + + override fun visitReturn(expression: IrReturn): IrExpression { + val result = super.visitReturn(expression) + if (result is IrReturn && result.returnTargetSymbol == function.symbol) { + result.value = rewriteResultExpression(result.value) + } + return result + } + + private fun rewriteResultExpression(expression: IrExpression): IrExpression { + when (expression) { + is IrConst -> if (expression.value is String) { + return replaceFoldedConstant(expression, receiver, tFunction) + } + is IrWhen -> expression.branches.forEach { branch -> + branch.result = rewriteResultExpression(branch.result) + } + is IrBlock -> { + val statements = expression.statements + val last = statements.lastOrNull() + if (last is IrExpression) { + statements[statements.size - 1] = rewriteResultExpression(last) + } + } + is IrTypeOperatorCall -> expression.argument = rewriteResultExpression(expression.argument) + is IrCall -> rewriteCallReceivers(expression) + else -> {} + } + return expression + } + + private fun rewriteCallReceivers(call: IrCall) { + val parameters = call.symbol.owner.parameters + for (index in parameters.indices) { + val kind = parameters[index].kind + if (kind != IrParameterKind.DispatchReceiver && kind != IrParameterKind.ExtensionReceiver) continue + val argument = call.arguments.getOrNull(index) ?: continue + call.arguments[index] = rewriteResultExpression(argument) + } + } + } + + /** Splits a folded string constant into a concatenation of its template parts, or returns it unchanged. */ + private fun replaceFoldedConstant( + irConst: IrConst, + receiver: IrValueParameter, + tFunction: IrSimpleFunction, + ): IrExpression { + val parts = classifyStringConst(irConst, enclosingKind = null, ConstPosition.STANDALONE, receiver, tFunction) + if (parts.size == 1 && parts[0] === irConst) { + return irConst + } + val builder = DeclarationIrBuilder(pluginContext, tFunction.symbol, irConst.startOffset, irConst.endOffset) + val concatenation = builder.irConcat() + concatenation.arguments.addAll(parts) + return concatenation + } + /** * Applies the template rules to the arguments of [expression]: literal text stays a fragment and every other * argument is wrapped in a `t()` call. @@ -175,17 +273,16 @@ class StormTemplateIrTransformer( ): IrExpression { val receiver = templateContextReceiver ?: return expression val tFunction = tFunctionSymbol ?: return expression + // The literal's own prefix determines how markers and escapes in its folded constants are interpreted. + val enclosingKind = if (operatorConcatenation) null else currentParser?.literalKindAt(expression.startOffset) // Recursively transform each argument first, so that nested TemplateBuilder lambdas (e.g., inside subquery - // calls) are processed before we wrap the argument in t(). + // calls) are processed before we wrap the argument in t(). Constants have no children to transform and are + // classified below instead, keeping their handling out of visitConst's standalone path. val newArguments = expression.arguments.flatMap { argument -> - val transformed = transformInPosition(argument, operatorConcatenation) + val transformed = if (argument is IrConst) argument else transformInPosition(argument, operatorConcatenation) when { - transformed is IrConst && isFragment(transformed) -> listOf(transformed) - transformed is IrConst && hasMergedConstant(transformed) -> - splitMergedConstant(transformed, receiver, tFunction) - // A string literal operand of a `+` chain is SQL text, like the literal part of a string template. - // Any other constant is interpolated, so that `+ 42` and `${42}` produce the same bind value. - transformed is IrConst && operatorConcatenation && transformed.value is String -> listOf(transformed) + transformed is IrConst && transformed.value is String -> + classifyStringConst(transformed, enclosingKind, ConstPosition.of(operatorConcatenation), receiver, tFunction) transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) @@ -234,140 +331,82 @@ class StormTemplateIrTransformer( } /** - * Checks whether an [IrConst] is a string template fragment (literal SQL text) as opposed to an interpolated - * constant expression like `${"value"}`. + * Applies the template rules to a string constant in text position and returns the expressions that replace it. * - * Fragment [IrConst] entries have a source offset span that matches the text length, because they represent literal - * text from the template. Interpolated constant expressions have a larger offset span, since their source range - * includes the surrounding syntax (e.g., quotes for string literals). + * A constant whose source text spells out its value verbatim is literal template text and stays a fragment. Any + * other constant is handed to [TemplateSourceParser]: when its source region parses as template syntax and the + * reassembled text equals the constant's value, the folded `${"..."}` interpolations become t()-wrapped values + * and the rest stays text. A region that contains interpolation syntax but cannot be split that way is reported + * as a compiler error; regions without interpolation syntax (plain literals, `+` chain operands, escaped text) + * are left unchanged. */ - private fun isFragment(irConst: IrConst): Boolean { - val value = irConst.value - if (value !is String) return false - return irConst.endOffset - irConst.startOffset == value.length - } - - /** - * Checks whether an [IrConst] contains a merged constant expression. This happens when the Kotlin compiler folds - * an inline constant expression like `${"value"}` with adjacent literal template text into a single [IrConst]. - * The merged entry has a source offset span larger than the text length, because the source range includes the - * `${"..."}` syntax in addition to the literal text. - */ - private fun hasMergedConstant(irConst: IrConst): Boolean { - val value = irConst.value - if (value !is String) return false - return irConst.endOffset - irConst.startOffset > value.length - } - - /** - * Splits a merged [IrConst] (containing both literal template text and folded inline constant expressions) into - * separate fragment and wrapped expression entries. - * - * This method reads the source file to find `${"..."}` patterns within the [IrConst]'s source range, then splits - * the merged text accordingly. Each inline constant expression is wrapped in a `t()` call, while literal text - * fragments remain as plain [IrConst] entries. - * - * If the source file cannot be read or the parsing fails (e.g., due to escape sequences in the constant), the - * original [IrConst] is returned unchanged to avoid incorrect transformations. - */ - private fun splitMergedConstant( + private fun classifyStringConst( irConst: IrConst, + enclosingKind: LiteralKind?, + position: ConstPosition, receiver: IrValueParameter, tFunction: IrSimpleFunction, ): List { - val sourceText = currentSourceText ?: return listOf(irConst) - val mergedText = irConst.value as String - val sourceStart = irConst.startOffset - val sourceEnd = irConst.endOffset - if (sourceStart < 0 || sourceEnd > sourceText.length) return listOf(irConst) - val source = sourceText.substring(sourceStart, sourceEnd) - // Find all ${"..."} patterns in the source and split the merged text. - // For multi-dollar strings (e.g., $$"...$${"value"}..."), the interpolation marker uses multiple $ signs. - // We scan backwards from the found ${" to include any additional $ signs that are part of the marker. - val result = mutableListOf() - var textPosition = 0 - var sourcePosition = 0 - while (sourcePosition < source.length) { - val dollarBraceQuote = source.indexOf("\${\"", sourcePosition) - if (dollarBraceQuote == -1) break - // Scan backwards to find the start of consecutive $ signs (handles $${"..."}, $$${"..."}, etc.). - var expressionStart = dollarBraceQuote - while (expressionStart > sourcePosition && source[expressionStart - 1] == '$') { - expressionStart-- + val value = irConst.value as String + val parser = currentParser + val start = irConst.startOffset + val end = irConst.endOffset + if (parser == null || start < 0 || end < start || !parser.inBounds(end)) { + // Without source text the constant cannot be verified. A concatenation argument whose source span does + // not match its value length either contains folded constants or escape sequences; neither can be told + // apart nor checked, which is worth one warning per file. Standalone constants are mostly ordinary + // literals whose span includes the quotes, so they stay silent. + if (position == ConstPosition.TEMPLATE_ARGUMENT && parser == null && end - start != value.length) { + reportUnverifiableTemplate(irConst) } - // Add the fragment before the expression. - val fragmentSourceLength = expressionStart - sourcePosition - if (fragmentSourceLength > 0) { - val fragmentText = mergedText.substring(textPosition, textPosition + fragmentSourceLength) - val fragmentSource = source.substring(sourcePosition, expressionStart) - if (fragmentSource != fragmentText) { - // Fragment contains escape sequences; cannot reliably split. - return listOf(irConst) - } - result.add(createStringConst( - sourceStart + sourcePosition, - sourceStart + expressionStart, - irConst.type, - fragmentText, - )) - textPosition += fragmentSourceLength - } - // Parse the string literal inside ${"..."} (or $${"..."}, etc.). - val contentStart = dollarBraceQuote + 3 // position after ${" - val closingQuote = findClosingQuote(source, contentStart) - if (closingQuote == -1) return listOf(irConst) // Malformed; leave unchanged. - val expressionSourceContent = source.substring(contentStart, closingQuote) - if (expressionSourceContent.contains('\\')) { - // Expression contains escape sequences; cannot reliably determine the runtime value. - return listOf(irConst) - } - val expressionValueLength = expressionSourceContent.length - if (textPosition + expressionValueLength > mergedText.length) return listOf(irConst) - val expressionValue = mergedText.substring(textPosition, textPosition + expressionValueLength) - if (expressionValue != expressionSourceContent) { - // Mismatch between source and merged text; cannot reliably split. + return listOf(unverifiedConst(irConst, position, receiver, tFunction)) + } + if (parser.matchesSource(start, end, value)) { + // Literal template text. + return listOf(irConst) + } + val parts = parser.parse(start, end, enclosingKind) + if (parts != null && parts.joinToString("") { it.text } == value) { + if (parts.none { it is TemplateValue }) { + // Literal text whose source spelling differs from its value: escape sequences or quoted operands. return listOf(irConst) } - // Wrap the expression value in t(). - val expressionConst = createStringConst( - sourceStart + dollarBraceQuote + 2, // position of opening " - sourceStart + closingQuote + 1, // position after closing " - irConst.type, - expressionValue, - ) - result.add(wrapInT(expressionConst, receiver, tFunction)) - textPosition += expressionValueLength - sourcePosition = closingQuote + 2 // skip past "} - } - // Add remaining fragment after the last expression. - if (textPosition < mergedText.length) { - val remainingFragment = mergedText.substring(textPosition) - val remainingSource = source.substring(sourcePosition) - if (remainingSource != remainingFragment) { - // Fragment contains escape sequences; cannot reliably split. - return listOf(irConst) + return parts.map { part -> + val partConst = createStringConst(part.startOffset, part.endOffset, irConst.type, part.text) + if (part is TemplateValue) wrapInT(partConst, receiver, tFunction) else partConst } - result.add(createStringConst( - sourceStart + sourcePosition, - sourceEnd, - irConst.type, - remainingFragment, - )) - } - return if (result.isEmpty()) listOf(irConst) else result - } - - /** Finds the position of the closing quote (`"`) in a string literal, handling escaped quotes. */ - private fun findClosingQuote(source: String, fromIndex: Int): Int { - var i = fromIndex - while (i < source.length) { - when (source[i]) { - '"' -> return i - '\\' -> i++ // Skip escaped character. + } + if (parser.sawInterpolation) { + if (parser.isInterpolationFold(start)) { + // The compiler folded a constant expression in place of a single interpolation, e.g. a const val + // reference. Such folds never merge with the surrounding template text, so the constant's value is + // the interpolation's value and binds like any other interpolated argument. + return listOf(wrapInT(irConst, receiver, tFunction)) } - i++ + reportUnsplittableConstant(irConst, start, end) + return listOf(irConst) } - return -1 + return listOf(unverifiedConst(irConst, position, receiver, tFunction)) + } + + /** + * The fallback for a string constant the source cannot vouch for. A constant whose source span is shorter than + * its value cannot be a fragment of the template's literal text; as the interpolated argument of a string + * template it yields a bind value, matching the treatment of non-constant arguments. In every other position + * the constant stays text: chain operands are literal by the template rules, and standalone constants are + * ordinary literals whose span includes the quotes. + */ + private fun unverifiedConst( + irConst: IrConst, + position: ConstPosition, + receiver: IrValueParameter, + tFunction: IrSimpleFunction, + ): IrExpression { + val value = irConst.value as String + if (position == ConstPosition.TEMPLATE_ARGUMENT && irConst.endOffset - irConst.startOffset < value.length) { + return wrapInT(irConst, receiver, tFunction) + } + return irConst } /** Creates a new string [IrConst] with the given offsets and value. */ @@ -420,6 +459,46 @@ class StormTemplateIrTransformer( body.statements.add(0, call) } + /** Reports a compiler error for a folded constant the plugin cannot split into template text and values. */ + private fun reportUnsplittableConstant(irConst: IrConst, start: Int, end: Int) { + val snippet = currentParser?.snippet(start, end) ?: irConst.value.toString() + report( + CompilerMessageSeverity.ERROR, + "Storm compiler plugin cannot determine which parts of this SQL template are text and which are " + + "values: the Kotlin compiler folded a constant expression into the surrounding template text " + + "($snippet). Interpolate the constant with an explicit t() or interpolate() call, or inline it " + + "as a plain string literal.", + irConst, + ) + } + + /** Reports, once per file, that folded constants cannot be verified because the source is unreadable. */ + private fun reportUnverifiableTemplate(irConst: IrConst) { + if (unverifiableReported) return + unverifiableReported = true + report( + CompilerMessageSeverity.WARNING, + "Storm compiler plugin cannot read the source of ${currentFile?.fileEntry?.name} to verify constant " + + "expressions folded into SQL templates; the folded constants are left as template text.", + irConst, + ) + } + + private fun report(severity: CompilerMessageSeverity, message: String, element: IrElement) { + val fileEntry = currentFile?.fileEntry + val location = if (fileEntry != null && element.startOffset >= 0) { + CompilerMessageLocation.create( + fileEntry.name, + fileEntry.getLineNumber(element.startOffset) + 1, + fileEntry.getColumnNumber(element.startOffset) + 1, + null, + ) + } else { + null + } + messageCollector.report(severity, message, location) + } + /** Resolves the `TemplateContext.t(Any?): String` function symbol. */ private fun resolveTFunction(): IrSimpleFunction? { val templateContextClass = pluginContext.referenceClass(TEMPLATE_CONTEXT_CLASS_ID) ?: return null @@ -434,3 +513,412 @@ class StormTemplateIrTransformer( .firstOrNull { it.name.asString() == "autoInterpolation" && it.parameters.none { p -> p.kind == IrParameterKind.Regular } } } } + +/** Where a string constant sits relative to the template being processed. */ +internal enum class ConstPosition { + /** An argument of a string template literal. */ + TEMPLATE_ARGUMENT, + + /** An operand of a `+` chain. */ + CHAIN_OPERAND, + + /** A constant that is not part of any concatenation. */ + STANDALONE; + + companion object { + fun of(operatorConcatenation: Boolean): ConstPosition = + if (operatorConcatenation) CHAIN_OPERAND else TEMPLATE_ARGUMENT + } +} + +/** + * A piece of a parsed template region. Offsets are absolute positions in the source file. + */ +internal sealed class TemplatePart { + abstract val startOffset: Int + abstract val endOffset: Int + abstract val text: String +} + +/** Literal SQL text. */ +internal class TemplateText( + override val startOffset: Int, + override val endOffset: Int, + override val text: String, +) : TemplatePart() + +/** A folded constant expression that yields a bind value. */ +internal class TemplateValue( + override val startOffset: Int, + override val endOffset: Int, + override val text: String, +) : TemplatePart() + +/** + * Interpolation syntax of a string literal: the number of dollar signs in an interpolation marker and whether the + * literal is raw (triple-quoted, no escape processing). + */ +internal data class LiteralKind(val dollars: Int, val raw: Boolean) + +/** + * Parses the source text behind a folded string constant back into the template pieces the constant was folded + * from: literal text and `${"..."}` constant expressions. + * + * Parsing is driven purely by the source text; the caller verifies the reassembled text against the constant's + * actual value, so a parse that would change the meaning of the template cannot go undetected. The parser handles + * the shapes the compiler produces: bare template content, content that starts at the inner literal of an + * interpolation whose `${` marker lies before the region, and complete quoted literals joined by `+`. Escape + * sequences are decoded in regular literals and taken verbatim in raw literals, and multi-dollar literals only + * treat runs of at least the marker's dollar count as interpolations. + */ +internal class TemplateSourceParser(private val fileText: String) { + + /** + * Set when the most recent [parse] encountered interpolation syntax, even if parsing subsequently failed. + * Distinguishes template-shaped source, whose failures must be loud, from plain constants. + */ + var sawInterpolation: Boolean = false + private set + + fun inBounds(offset: Int): Boolean = offset in 0..fileText.length + + /** Checks whether the source region [start] until [end] spells out [value] verbatim. */ + fun matchesSource(start: Int, end: Int, value: String): Boolean = + end - start == value.length && fileText.regionMatches(start, value, 0, value.length) + + /** A condensed, quoted rendering of the source region for diagnostics. */ + fun snippet(start: Int, end: Int): String { + val region = fileText.substring(start, end).replace("\n", "\\n") + return if (region.length <= 60) "'$region'" else "'${region.take(57)}...'" + } + + /** The literal kind of the string literal that starts at [offset], or null when the offset does not sit on one. */ + fun literalKindAt(offset: Int): LiteralKind? { + if (offset < 0 || offset >= fileText.length) return null + return literalPrefix(offset)?.first + } + + /** + * Checks whether the region at [start] is the folded form of a single interpolation: its source sits directly + * inside interpolation syntax, `$name` or `${name}`, rather than starting at a string literal. The compiler + * folds such constants in place of the interpolation without merging the surrounding template text, so the + * constant's value is the interpolation's value. Merged constants always start at literal syntax instead. + */ + fun isInterpolationFold(start: Int): Boolean { + if (literalPrefix(start) != null) return false + return markerBehind(start) != null || (start > 0 && fileText[start - 1] == '$') + } + + /** + * Parses the region [start] until [end] into template parts, or returns null when the region cannot be related + * to template syntax. [enclosingKind] is the kind of the string literal the region belongs to, when known. + */ + fun parse(start: Int, end: Int, enclosingKind: LiteralKind?): List? { + sawInterpolation = false + if (start < 0 || end < start || end > fileText.length) return null + markerBehind(start)?.let { dollars -> + // The region starts at the inner literal of an interpolation whose marker lies before it. + sawInterpolation = true + if (enclosingKind != null) { + return parseContinuation(start, end, LiteralKind(dollars, enclosingKind.raw)) + } + // The enclosing literal's kind is unknown; the value verification in the caller picks the attempt + // that reproduces the constant. + return parseContinuation(start, end, LiteralKind(dollars, raw = false)) + ?: parseContinuation(start, end, LiteralKind(dollars, raw = true)) + } + if (start > 0 && fileText[start - 1] == '$') { + // The region starts at the identifier of a simple-name interpolation like $CONST: the folded value + // cannot be recovered from the source. + sawInterpolation = true + return null + } + if (literalPrefix(start) != null) { + return parseLiteralChain(start, end) + } + val kind = enclosingKind ?: openingQuoteBehind(start) ?: return null + val parts = mutableListOf() + val consumed = scanContent(start, end, kind, parts, stopAtClosingQuote = false) ?: return null + if (consumed != end) return null + return parts + } + + /** Parses a region that starts inside an interpolation: the inner literal, its closing brace, then content. */ + private fun parseContinuation(start: Int, end: Int, kind: LiteralKind): List? { + val parts = mutableListOf() + val pos = parseInterpolationTail(start, parts) ?: return null + if (pos >= end) return parts // The region ends inside the interpolation's closing syntax. + val consumed = scanContent(pos, end, kind, parts, stopAtClosingQuote = false) ?: return null + if (consumed != end) return null + return parts + } + + /** + * Parses the inner constant of an interpolation plus its closing brace, appending the constant as a value part. + * The region of a folded constant can end anywhere inside the closing syntax, so the constant and the brace are + * matched against the file rather than the region. Returns the position after the brace. + */ + private fun parseInterpolationTail(start: Int, parts: MutableList): Int? { + var pos = parseInnerConstant(start, parts) ?: return null + while (pos < fileText.length && fileText[pos].isWhitespace()) pos++ + if (pos >= fileText.length || fileText[pos] != '}') return null + return pos + 1 + } + + /** + * Parses the constant expression of an interpolation and appends it as a single value part. Supported are the + * literals whose string rendering can be derived from the source: strings, characters, booleans, and integers. + * The rendering is verified against the folded value by the caller, so an unexpected rendering surfaces as an + * unsplittable constant rather than a wrong split. Returns the position after the constant, or null when the + * expression is not a supported literal. + */ + private fun parseInnerConstant(start: Int, parts: MutableList): Int? { + if (start >= fileText.length) return null + if (literalPrefix(start) != null) { + return parseInnerLiteral(start, parts) + } + if (fileText[start] == '\'') { + return parseCharLiteral(start, parts) + } + for (keyword in listOf("true", "false")) { + val end = start + keyword.length + if (fileText.startsWith(keyword, start) && (end >= fileText.length || !isIdentifierPart(fileText[end]))) { + parts.add(TemplateValue(start, end, keyword)) + return end + } + } + return parseIntegerLiteral(start, parts) + } + + /** Parses a character literal like `'c'` or `'\n'` and appends it as a value part. */ + private fun parseCharLiteral(start: Int, parts: MutableList): Int? { + var pos = start + 1 + if (pos >= fileText.length) return null + val text: String + if (fileText[pos] == '\\') { + val decoded = decodeEscape(pos, fileText.length) ?: return null + text = decoded.first + pos = decoded.second + } else { + text = fileText[pos].toString() + pos++ + } + if (pos >= fileText.length || fileText[pos] != '\'') return null + parts.add(TemplateValue(start, pos + 1, text)) + return pos + 1 + } + + /** Parses an integer literal, decimal, hexadecimal, or binary, with optional sign and suffixes. */ + private fun parseIntegerLiteral(start: Int, parts: MutableList): Int? { + var pos = start + var sign = "" + if (pos < fileText.length && fileText[pos] == '-') { + sign = "-" + pos++ + } + if (pos >= fileText.length || !fileText[pos].isDigit()) return null + val radix: Int + val digitsStart: Int + when { + fileText.startsWith("0x", pos) || fileText.startsWith("0X", pos) -> { + radix = 16 + digitsStart = pos + 2 + } + fileText.startsWith("0b", pos) || fileText.startsWith("0B", pos) -> { + radix = 2 + digitsStart = pos + 2 + } + else -> { + radix = 10 + digitsStart = pos + } + } + pos = digitsStart + val digits = StringBuilder() + while (pos < fileText.length && (Character.digit(fileText[pos], radix) >= 0 || fileText[pos] == '_')) { + if (fileText[pos] != '_') digits.append(fileText[pos]) + pos++ + } + if (digits.isEmpty()) return null + if (pos < fileText.length && (fileText[pos] == '.' || fileText[pos].lowercaseChar() in "ef")) { + // A floating-point literal; its rendering is not derived here. + return null + } + if (pos < fileText.length && (fileText[pos] == 'u' || fileText[pos] == 'U')) pos++ + if (pos < fileText.length && fileText[pos] == 'L') pos++ + parts.add(TemplateValue(start, pos, sign + java.math.BigInteger(digits.toString(), radix))) + return pos + } + + private fun isIdentifierPart(c: Char): Boolean = c.isLetterOrDigit() || c == '_' + + /** Parses one or more complete quoted literals joined by `+`, the shape of a folded operator chain. */ + private fun parseLiteralChain(start: Int, end: Int): List? { + val parts = mutableListOf() + var pos = start + while (true) { + val (kind, contentStart) = literalPrefix(pos) ?: return null + pos = scanContent(contentStart, end, kind, parts, stopAtClosingQuote = true) ?: return null + while (pos < end && fileText[pos].isWhitespace()) pos++ + if (pos == end) return parts + if (fileText[pos] != '+') return null + pos++ + while (pos < end && fileText[pos].isWhitespace()) pos++ + } + } + + /** + * Parses the string literal of an interpolation and appends it as a single value part. The literal is scanned + * against the whole file because a folded constant's region can end before the literal's closing quote. Returns + * the position after the closing quote, or null when the interpolated expression is not a string literal. + */ + private fun parseInnerLiteral(start: Int, parts: MutableList): Int? { + val (kind, contentStart) = literalPrefix(start) ?: return null + val inner = mutableListOf() + val afterContent = scanContent(contentStart, fileText.length, kind, inner, stopAtClosingQuote = true, allowMarkers = false) + ?: return null + parts.add(TemplateValue(start, afterContent, inner.joinToString("") { it.text })) + return afterContent + } + + /** + * Scans literal content, decoding escape sequences and splitting out `${"..."}` interpolations, and appends the + * resulting text and value parts to [parts]. Returns the position after the content: after the closing quotes + * when [stopAtClosingQuote] is set, [limit] otherwise. Returns null when the content cannot be a folded + * constant template, e.g. an interpolation of anything but a string literal. + */ + private fun scanContent( + start: Int, + limit: Int, + kind: LiteralKind, + parts: MutableList, + stopAtClosingQuote: Boolean, + allowMarkers: Boolean = true, + ): Int? { + val chunk = StringBuilder() + var chunkStart = start + var pos = start + + fun flushChunk(endOffset: Int) { + if (chunk.isNotEmpty()) { + parts.add(TemplateText(chunkStart, endOffset, chunk.toString())) + chunk.setLength(0) + } + } + + while (pos < limit) { + val c = fileText[pos] + if (c == '"' && stopAtClosingQuote) { + var runEnd = pos + while (runEnd < limit && fileText[runEnd] == '"') runEnd++ + val runLength = runEnd - pos + if (!kind.raw) { + flushChunk(pos) + return pos + 1 + } + if (runLength >= 3) { + // The final three quotes close a raw literal; preceding quotes in the run are content. + repeat(runLength - 3) { chunk.append('"') } + flushChunk(runEnd - 3) + return runEnd + } + repeat(runLength) { chunk.append('"') } + pos = runEnd + continue + } + if (c == '\\' && !kind.raw) { + val decoded = decodeEscape(pos, limit) ?: return null + chunk.append(decoded.first) + pos = decoded.second + continue + } + if (c == '$') { + var runEnd = pos + while (runEnd < limit && fileText[runEnd] == '$') runEnd++ + val dollars = runEnd - pos + val next = if (runEnd < limit) fileText[runEnd] else '' + if (dollars >= kind.dollars && (next == '{' || isIdentifierStart(next))) { + sawInterpolation = true + if (!allowMarkers) return null + if (next != '{') { + // Simple-name interpolation: the folded value cannot be recovered from the source. + return null + } + // Dollars beyond the marker's count are literal text before the marker. + repeat(dollars - kind.dollars) { chunk.append('$') } + flushChunk(pos + (dollars - kind.dollars)) + var innerPos = runEnd + 1 + while (innerPos < fileText.length && fileText[innerPos].isWhitespace()) innerPos++ + pos = parseInterpolationTail(innerPos, parts) ?: return null + chunkStart = pos + if (pos >= limit) { + // The region ends inside the interpolation's closing syntax. + return if (stopAtClosingQuote) null else limit + } + continue + } + repeat(dollars) { chunk.append('$') } + pos = runEnd + continue + } + chunk.append(c) + pos++ + } + if (stopAtClosingQuote) return null // Truncated literal: the closing quote lies beyond the region. + flushChunk(limit) + return limit + } + + /** Recognizes the dollars-and-quotes prefix of a string literal at [pos], e.g. `"`, `"""`, or `$$"`. */ + private fun literalPrefix(pos: Int): Pair? { + var p = pos + while (p < fileText.length && fileText[p] == '$') p++ + val dollars = p - pos + if (p >= fileText.length || fileText[p] != '"') return null + val raw = fileText.startsWith("\"\"\"", p) + return LiteralKind(maxOf(dollars, 1), raw) to p + if (raw) 3 else 1 + } + + /** The number of marker dollars when the characters directly before [start] are `$`-run plus `{`, or null. */ + private fun markerBehind(start: Int): Int? { + if (start < 2 || fileText[start - 1] != '{') return null + var p = start - 2 + while (p >= 0 && fileText[p] == '$') p-- + val dollars = start - 2 - p + return if (dollars >= 1) dollars else null + } + + /** The literal kind when [start] sits directly after an opening quote, or null. */ + private fun openingQuoteBehind(start: Int): LiteralKind? { + var p = start - 1 + while (p >= 0 && fileText[p] == '"') p-- + val quotes = start - 1 - p + if (quotes != 1 && quotes < 3) return null + var d = p + while (d >= 0 && fileText[d] == '$') d-- + return LiteralKind(maxOf(p - d, 1), raw = quotes >= 3) + } + + /** Decodes the escape sequence at [pos]. Returns the decoded text and the position after the sequence. */ + private fun decodeEscape(pos: Int, limit: Int): Pair? { + if (pos + 1 >= limit) return null + return when (fileText[pos + 1]) { + 't' -> "\t" to pos + 2 + 'b' -> "\b" to pos + 2 + 'n' -> "\n" to pos + 2 + 'r' -> "\r" to pos + 2 + '\'' -> "'" to pos + 2 + '"' -> "\"" to pos + 2 + '\\' -> "\\" to pos + 2 + '$' -> "$" to pos + 2 + 'u' -> { + if (pos + 6 > limit) return null + val code = fileText.substring(pos + 2, pos + 6).toIntOrNull(16) ?: return null + code.toChar().toString() to pos + 6 + } + else -> null + } + } + + private fun isIdentifierStart(c: Char): Boolean = c.isLetter() || c == '_' || c == '`' +} diff --git a/storm-compiler-plugin/src/main/kotlin/st/orm/kotlin/plugin/StormTemplateIrGenerationExtension.kt b/storm-compiler-plugin/src/main/kotlin/st/orm/kotlin/plugin/StormTemplateIrGenerationExtension.kt index d89a62ef8..ee7444f82 100644 --- a/storm-compiler-plugin/src/main/kotlin/st/orm/kotlin/plugin/StormTemplateIrGenerationExtension.kt +++ b/storm-compiler-plugin/src/main/kotlin/st/orm/kotlin/plugin/StormTemplateIrGenerationExtension.kt @@ -2,16 +2,20 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.cli.common.messages.MessageCollector import org.jetbrains.kotlin.ir.declarations.IrModuleFragment import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid /** * IR generation extension that rewrites string template interpolations inside [TemplateBuilder] lambdas. Delegates to - * [StormTemplateIrTransformer] for the actual transformation. + * [StormTemplateIrTransformer] for the actual transformation. Diagnostics, e.g. for folded constants that cannot be + * split back into template text and values, are reported through [messageCollector]. */ -class StormTemplateIrGenerationExtension : IrGenerationExtension { +class StormTemplateIrGenerationExtension( + private val messageCollector: MessageCollector = MessageCollector.NONE, +) : IrGenerationExtension { override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { - moduleFragment.transformChildrenVoid(StormTemplateIrTransformer(pluginContext)) + moduleFragment.transformChildrenVoid(StormTemplateIrTransformer(pluginContext, messageCollector)) } } diff --git a/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt b/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt index cac007758..80256303a 100644 --- a/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt +++ b/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt @@ -5,6 +5,7 @@ import com.tschuchort.compiletesting.KotlinCompilation import com.tschuchort.compiletesting.SourceFile import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test @@ -1027,4 +1028,261 @@ class StormTemplatePluginTest { assertEquals("SELECT COUNT(*) FROM users", lines[0]) assertEquals("0", lines[1]) } + + // -- Folded constant tests -- + // + // The compiler folds constant interpolations like ${"value"} into the surrounding template text before the + // plugin runs. The plugin parses the source to split such constants back into text and values, verifying the + // result against the folded value, and reports a compiler error when a constant cannot be split. These tests + // pin the split for the source shapes that used to defeat it: escape sequences, adjacent constants, fully + // constant templates, and multi-dollar interpolation. + + private fun JvmCompilationResult.runMainEscaped(): List { + val mainClass = classLoader.loadClass("TestKt") + val oldOut = System.out + val capture = java.io.ByteArrayOutputStream() + System.setOut(java.io.PrintStream(capture)) + try { + mainClass.getMethod("main").invoke(null) + } finally { + System.setOut(oldOut) + } + return capture.toString().trim().lines() + } + + /** Compiles a TemplateBuilder body and asserts the resulting fragments and values, newlines and tabs escaped. */ + private fun assertTemplate(body: String, expectedFragments: String, expectedValues: String, languageVersion: String = "2.0", prelude: String = "") { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + $prelude + + fun main() { + val builder: TemplateBuilder = { $body } + val result = builder.build() + println(result.fragments.joinToString("|").replace("\n", "\\n").replace("\t", "\\t")) + println(result.values.joinToString(",").replace("\n", "\\n")) + } + """, + ) + val result = compile(source, languageVersion = languageVersion) + if (languageVersion != "2.0") { + assumeCompilationSuccess(result) + } + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val lines = result.runMainEscaped() + assertEquals(expectedFragments, lines[0]) + assertEquals(expectedValues, lines.getOrElse(1) { "" }) + } + + /** + * Compiles a TemplateBuilder body whose folded constant the plugin cannot split. Compiler versions that fold + * the constant must report an error rather than leave the interpolation as SQL text; versions that keep the + * interpolation as a runtime value already bind it correctly and must compile. + */ + private fun assertUnsplittableConstant(body: String, expectedFragments: String, expectedValues: String, prelude: String = "") { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + $prelude + + fun main() { + val builder: TemplateBuilder = { $body } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + if (result.exitCode == KotlinCompilation.ExitCode.OK) { + val lines = result.runMainEscaped() + assertEquals(expectedFragments, lines[0]) + assertEquals(expectedValues, lines.getOrElse(1) { "" }) + } else { + assertEquals(KotlinCompilation.ExitCode.COMPILATION_ERROR, result.exitCode, result.messages) + assertTrue( + result.messages.contains("Storm compiler plugin cannot determine"), + "Expected the Storm unsplittable-constant error, got: ${result.messages}", + ) + } + } + + @Test + fun `escape sequence before inline constant is split`() { + assertTemplate(""" "a\nb${'$'}{"c"}d" """.trim(), """a\nb|d""", "c") + } + + @Test + fun `escape sequence before inline constant at end of template is split`() { + assertTemplate(""" "a\tb${'$'}{"c"}" """.trim(), """a\tb|""", "c") + } + + @Test + fun `escaped quote before inline constant is split`() { + assertTemplate(""" "a\"b${'$'}{"c"}" """.trim(), """a"b|""", "c") + } + + @Test + fun `unicode escape before inline constant is split`() { + assertTemplate(""" "a\u00e9${'$'}{"c"}" """.trim(), "aƩ|", "c") + } + + @Test + fun `escape sequence after inline constant is split`() { + assertTemplate(""" "a${'$'}{"c"}\nd" """.trim(), """a|\nd""", "c") + } + + @Test + fun `escape sequence inside inline constant is split`() { + assertTemplate(""" "x${'$'}{"a\nb"}y" """.trim(), "x|y", """a\nb""") + } + + @Test + fun `escaped dollar before inline constant is split`() { + assertTemplate(""" "a\${'$'}x ${'$'}{"c"} b" """.trim(), "a${'$'}x | b", "c") + } + + @Test + fun `escaped interpolation marker stays text`() { + assertTemplate(""" "a\${'$'}{x}b" """.trim(), "a${'$'}{x}b", "") + } + + @Test + fun `template consisting of only an inline constant is a value`() { + assertTemplate(""" "${'$'}{"c"}" """.trim(), "|", "c") + } + + @Test + fun `adjacent inline constants are values`() { + assertTemplate(""" "${'$'}{"a"}${'$'}{"b"}" """.trim(), "||", "a,b") + } + + @Test + fun `leading inline constant is a value`() { + assertTemplate(""" "${'$'}{"a"} b" """.trim(), "| b", "a") + } + + @Test + fun `raw string with backslash before inline constant is split`() { + assertTemplate("\"\"\"a\\n${'$'}{\"c\"}b\"\"\"", """a\n|b""", "c") + } + + @Test + fun `chain operand with escape and inline constant is split`() { + assertTemplate(""" "a\n" + "b${'$'}{"c"}d" """.trim(), """a\nb|d""", "c") + } + + @Test + fun `inline int constant is a value`() { + assertTemplate(""" "LIMIT ${'$'}{42}" """.trim(), "LIMIT |", "42") + } + + @Test + fun `inline char constant is a value`() { + assertTemplate(""" "a${'$'}{'c'}b" """.trim(), "a|b", "c") + } + + @Test + fun `inline boolean constant is a value`() { + assertTemplate(""" "WHERE active = ${'$'}{true}" """.trim(), "WHERE active = |", "true") + } + + @Test + fun `inline constant with whitespace inside braces is a value`() { + assertTemplate(""" "x${'$'}{ "c" }y" """.trim(), "x|y", "c") + } + + @Test + fun `multi-dollar escape before inline constant is split`() { + assertTemplate( + """ ${'$'}${'$'}"a\nb${'$'}${'$'}{"c"}d" """.trim(), + """a\nb|d""", + "c", + languageVersion = "2.2", + ) + } + + @Test + fun `multi-dollar literal marker with inline constant stays text`() { + assertTemplate( + """ ${'$'}${'$'}"WHERE ${'$'}{x} = ${'$'}${'$'}{"c"}" """.trim(), + "WHERE ${'$'}{x} = |", + "c", + languageVersion = "2.2", + ) + } + + @Test + fun `multi-dollar surplus dollar before inline constant stays text`() { + assertTemplate( + """ ${'$'}${'$'}"a${'$'}${'$'}${'$'}{"c"}b" """.trim(), + "a${'$'}|b", + "c", + languageVersion = "2.2", + ) + } + + @Test + fun `fully constant raw string with trimIndent is split`() { + assertTemplate( + "\"\"\"SELECT ${'$'}{\"c\"} FROM users\"\"\".trimIndent()", + "SELECT | FROM users", + "c", + ) + } + + @Test + fun `fully constant conditional branches are split`() { + assertTemplate( + """ if (System.currentTimeMillis() > 0) "a${'$'}{"c"}b" else "x${'$'}{"y"}z" """.trim(), + "a|b", + "c", + ) + } + + @Test + fun `folded constant reference is a value`() { + assertTemplate( + """ + val id = 42 + "a ${'$'}id ${'$'}{LIMIT} b" + """.trimIndent(), + "a | | b", + "42,10", + prelude = """const val LIMIT = "10"""", + ) + } + + @Test + fun `folded simple-name constant reference is a value`() { + assertTemplate( + """ "a ${'$'}LIMIT b" """.trim(), + "a | b", + "10", + prelude = """const val LIMIT = "10"""", + ) + } + + @Test + fun `numbers in template text stay text`() { + assertTemplate(""" "SELECT name FROM users LIMIT 5" """.trim(), "SELECT name FROM users LIMIT 5", "") + } + + @Test + fun `numbers in template text next to an inline constant stay text`() { + assertTemplate(""" "SELECT ${'$'}{"name"} FROM users LIMIT 5" """.trim(), "SELECT | FROM users LIMIT 5", "name") + } + + @Test + fun `inline float constant binds or is reported`() { + // Kotlin 2.0 folds numeric interpolations into the template text; a float's rendering is not derived from + // the source, so the fold must surface as a compiler error rather than SQL text. Later compilers keep the + // interpolation as a runtime value. + assertUnsplittableConstant(""" "LIMIT ${'$'}{1.5}" """.trim(), "LIMIT |", "1.5") + } }