From 6705e4977269c4562c8ba529f803e0fea97f3e8e Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Thu, 16 Jul 2026 09:29:14 +0200 Subject: [PATCH] Kotlin recipe DSL: preserve scan/generate phases in K2 synthesis Imperative recipe(...) calls were synthesized as a plain Recipe overriding only getVisitor(), so scan { }.generate { } and bare generate { } emitted nothing and scan { }.edit { } ran against an empty accumulator. Recipes that declare a scan/generate phase are now synthesized as a field-less ScanningRecipe subclass whose lifecycle methods delegate to new buildImperative* runtime helpers, preserving every phase. --- .../main/kotlin/org/openrewrite/RecipeDsl.kt | 58 ++++ .../internal/RecipeIrGenerationExtension.kt | 258 +++++++++++++++++- .../kotlin/recipe/RecipePluginScanningTest.kt | 208 ++++++++++++++ 3 files changed, 514 insertions(+), 10 deletions(-) create mode 100644 rewrite-kotlin/src/test/kotlin/org/openrewrite/kotlin/recipe/RecipePluginScanningTest.kt diff --git a/rewrite-kotlin/src/main/kotlin/org/openrewrite/RecipeDsl.kt b/rewrite-kotlin/src/main/kotlin/org/openrewrite/RecipeDsl.kt index 3a78aaba758..fba2793884a 100644 --- a/rewrite-kotlin/src/main/kotlin/org/openrewrite/RecipeDsl.kt +++ b/rewrite-kotlin/src/main/kotlin/org/openrewrite/RecipeDsl.kt @@ -713,3 +713,61 @@ public fun buildImperativeVisitor( builder.block() return builder.build("", "", emptySet(), null).getVisitor() } + +private fun buildImperativeRecipe(block: RecipeBuilder.() -> Unit): Recipe { + val builder = RecipeBuilder() + builder.block() + return builder.build("", "", emptySet(), null) +} + +/** + * Scanning counterparts to [buildImperativeVisitor], one per [ScanningRecipe] + * lifecycle method. The K2 plugin's synthesized `$KtRecipe` extends + * [ScanningRecipe] and routes each call here so all phases survive synthesis. + * The accumulator is threaded by the framework, so the generated class stays + * field-less; an edit-only block leaves [buildImperativeRecipe] returning a + * plain [Recipe], for which the scanner/generate helpers no-op. + */ +public fun buildImperativeInitialValue( + ctx: ExecutionContext, + block: RecipeBuilder.() -> Unit, +): Any { + val recipe = buildImperativeRecipe(block) + @Suppress("UNCHECKED_CAST") + return if (recipe is ScanningRecipe<*>) (recipe as ScanningRecipe).getInitialValue(ctx) else Unit +} + +public fun buildImperativeScanner( + acc: Any, + block: RecipeBuilder.() -> Unit, +): TreeVisitor<*, ExecutionContext> { + val recipe = buildImperativeRecipe(block) + @Suppress("UNCHECKED_CAST") + return if (recipe is ScanningRecipe<*>) (recipe as ScanningRecipe).getScanner(acc) + else TreeVisitor.noop() +} + +public fun buildImperativeEditVisitor( + acc: Any, + block: RecipeBuilder.() -> Unit, +): TreeVisitor<*, ExecutionContext> { + val recipe = buildImperativeRecipe(block) + @Suppress("UNCHECKED_CAST") + return if (recipe is ScanningRecipe<*>) (recipe as ScanningRecipe).getVisitor(acc) + else recipe.getVisitor() +} + +public fun buildImperativeGenerate( + acc: Any, + generatedInThisCycle: Collection, + ctx: ExecutionContext, + block: RecipeBuilder.() -> Unit, +): Collection { + val recipe = buildImperativeRecipe(block) + @Suppress("UNCHECKED_CAST") + return if (recipe is ScanningRecipe<*>) { + (recipe as ScanningRecipe).generate(acc, generatedInThisCycle, ctx).toList() + } else { + emptyList() + } +} diff --git a/rewrite-kotlin/src/main/kotlin/org/openrewrite/kotlin/recipe/internal/RecipeIrGenerationExtension.kt b/rewrite-kotlin/src/main/kotlin/org/openrewrite/kotlin/recipe/internal/RecipeIrGenerationExtension.kt index 16cd38e22e1..34acec9ace2 100644 --- a/rewrite-kotlin/src/main/kotlin/org/openrewrite/kotlin/recipe/internal/RecipeIrGenerationExtension.kt +++ b/rewrite-kotlin/src/main/kotlin/org/openrewrite/kotlin/recipe/internal/RecipeIrGenerationExtension.kt @@ -33,11 +33,13 @@ import org.jetbrains.kotlin.descriptors.Modality import org.jetbrains.kotlin.ir.IrElement import org.jetbrains.kotlin.ir.builders.declarations.addConstructor import org.jetbrains.kotlin.ir.builders.declarations.addFunction +import org.jetbrains.kotlin.ir.builders.declarations.addValueParameter import org.jetbrains.kotlin.ir.builders.declarations.buildClass import org.jetbrains.kotlin.ir.builders.irBlockBody import org.jetbrains.kotlin.ir.builders.irCall import org.jetbrains.kotlin.ir.builders.irCallConstructor import org.jetbrains.kotlin.ir.builders.irDelegatingConstructorCall +import org.jetbrains.kotlin.ir.builders.irGet import org.jetbrains.kotlin.ir.builders.irInt import org.jetbrains.kotlin.ir.builders.irReturn import org.jetbrains.kotlin.ir.builders.irString @@ -69,6 +71,7 @@ import org.jetbrains.kotlin.ir.symbols.IrValueSymbol import org.jetbrains.kotlin.ir.types.IrType import org.jetbrains.kotlin.ir.types.classFqName import org.jetbrains.kotlin.ir.types.defaultType +import org.jetbrains.kotlin.ir.types.typeWith import org.jetbrains.kotlin.ir.util.addChild import org.jetbrains.kotlin.ir.util.createThisReceiverParameter import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols @@ -134,6 +137,8 @@ internal class RecipeIrGenerationExtension : IrGenerationExtension { const val EDIT_SCOPE_REWRITE_FQN = "org.openrewrite.EditScope.rewrite" const val RECIPE_BUILDER_EDIT_FQN = "org.openrewrite.RecipeBuilder.edit" + const val RECIPE_BUILDER_SCAN_FQN = "org.openrewrite.RecipeBuilder.scan" + const val RECIPE_BUILDER_GENERATE_FQN = "org.openrewrite.RecipeBuilder.generate" /** * K2 FIR2IR represents `expr!!` as a call to this synthetic intrinsic, @@ -216,7 +221,32 @@ internal class RecipeIrGenerationExtension : IrGenerationExtension { * DSL's anonymous-Recipe path. */ val buildImperativeVisitorSymbol: IrSimpleFunctionSymbol?, - ) + // ScanningRecipe + its lifecycle methods and the buildImperative* helpers, + // for the scan/generate synthesis path. Any null (older rewrite-kotlin + // without the helpers) leaves the recipe as a runtime `recipe(...)` call. + val scanningRecipeClassSymbol: IrClassSymbol?, + val scanningRecipeNoArgCtorSymbol: IrConstructorSymbol?, + val scanningGetInitialValue: IrSimpleFunction?, + val scanningGetScanner: IrSimpleFunction?, + val scanningGetVisitor: IrSimpleFunction?, + val scanningGenerate: IrSimpleFunction?, + val buildImperativeInitialValueSymbol: IrSimpleFunctionSymbol?, + val buildImperativeScannerSymbol: IrSimpleFunctionSymbol?, + val buildImperativeEditVisitorSymbol: IrSimpleFunctionSymbol?, + val buildImperativeGenerateSymbol: IrSimpleFunctionSymbol?, + ) { + fun canSynthesizeScanningRecipe(): Boolean = + scanningRecipeClassSymbol != null && + scanningRecipeNoArgCtorSymbol != null && + scanningGetInitialValue != null && + scanningGetScanner != null && + scanningGetVisitor != null && + scanningGenerate != null && + buildImperativeInitialValueSymbol != null && + buildImperativeScannerSymbol != null && + buildImperativeEditVisitorSymbol != null && + buildImperativeGenerateSymbol != null + } private fun buildIrGenContext(pluginContext: IrPluginContext): RecipeIrGenContext? { val recipeClassId = ClassId.topLevel(FqName("org.openrewrite.Recipe")) @@ -286,6 +316,36 @@ internal class RecipeIrGenerationExtension : IrGenerationExtension { )) .singleOrNull { it.owner.valueParameters.size == 1 } + val scanningRecipeClassId = ClassId.topLevel(FqName("org.openrewrite.ScanningRecipe")) + val scanningRecipeClassSymbol = pluginContext.referenceClass(scanningRecipeClassId) + val scanningRecipeNoArgCtor = pluginContext.referenceConstructors(scanningRecipeClassId) + .singleOrNull { it.owner.valueParameters.isEmpty() } + val scanningMembers = scanningRecipeClassSymbol?.owner?.declarations + ?.filterIsInstance().orEmpty() + val scanningGetInitialValue = scanningMembers.firstOrNull { + it.name.asString() == "getInitialValue" && it.valueParameters.size == 1 + } + val scanningGetScanner = scanningMembers.firstOrNull { + it.name.asString() == "getScanner" && it.valueParameters.size == 1 + } + val scanningGetVisitor = scanningMembers.firstOrNull { + it.name.asString() == "getVisitor" && it.valueParameters.size == 1 + } + val scanningGenerate = scanningMembers.firstOrNull { + it.name.asString() == "generate" && it.valueParameters.size == 3 + } + + fun topLevelHelper(name: String, arity: Int): IrSimpleFunctionSymbol? = pluginContext + .referenceFunctions(org.jetbrains.kotlin.name.CallableId( + packageName = FqName("org.openrewrite"), + callableName = Name.identifier(name), + )) + .singleOrNull { it.owner.valueParameters.size == arity } + val buildImperativeInitialValueSymbol = topLevelHelper("buildImperativeInitialValue", 2) + val buildImperativeScannerSymbol = topLevelHelper("buildImperativeScanner", 2) + val buildImperativeEditVisitorSymbol = topLevelHelper("buildImperativeEditVisitor", 2) + val buildImperativeGenerateSymbol = topLevelHelper("buildImperativeGenerate", 4) + return RecipeIrGenContext( pluginContext = pluginContext, recipeClassSymbol = recipeClassSymbol, @@ -302,6 +362,16 @@ internal class RecipeIrGenerationExtension : IrGenerationExtension { methodInvocationRewriteKotlinNotNullSymbol = kotlinNotNullHelper, propertyAccessRewriteKotlinSymbol = propertyAccessHelper, buildImperativeVisitorSymbol = buildImperativeVisitorSymbol, + scanningRecipeClassSymbol = scanningRecipeClassSymbol, + scanningRecipeNoArgCtorSymbol = scanningRecipeNoArgCtor, + scanningGetInitialValue = scanningGetInitialValue, + scanningGetScanner = scanningGetScanner, + scanningGetVisitor = scanningGetVisitor, + scanningGenerate = scanningGenerate, + buildImperativeInitialValueSymbol = buildImperativeInitialValueSymbol, + buildImperativeScannerSymbol = buildImperativeScannerSymbol, + buildImperativeEditVisitorSymbol = buildImperativeEditVisitorSymbol, + buildImperativeGenerateSymbol = buildImperativeGenerateSymbol, ) } @@ -358,15 +428,29 @@ internal class RecipeIrGenerationExtension : IrGenerationExtension { ) } else { val imperativeBlock = findTrailingLambda(initializerExpr) ?: continue - val helperSymbol = ctx.buildImperativeVisitorSymbol ?: continue - buildImperativeRecipeClass( - ctx = ctx, - parentFile = file, - propertyName = declaration.name, - metadata = metadata, - recipeBlock = imperativeBlock, - helperSymbol = helperSymbol, - ) + // A scan/generate recipe must extend ScanningRecipe or the + // scan/generate phases are silently dropped; edit-only keeps + // the lighter Recipe + getVisitor synthesis. + if (recipeBlockDeclaresScanOrGenerate(imperativeBlock)) { + if (!ctx.canSynthesizeScanningRecipe()) continue + buildScanningRecipeClass( + ctx = ctx, + parentFile = file, + propertyName = declaration.name, + metadata = metadata, + recipeBlock = imperativeBlock, + ) + } else { + val helperSymbol = ctx.buildImperativeVisitorSymbol ?: continue + buildImperativeRecipeClass( + ctx = ctx, + parentFile = file, + propertyName = declaration.name, + metadata = metadata, + recipeBlock = imperativeBlock, + helperSymbol = helperSymbol, + ) + } } } RECIPES_FQN -> { @@ -1845,6 +1929,160 @@ internal class RecipeIrGenerationExtension : IrGenerationExtension { } } + // ------------------------------------------------------------------ + // Scanning-shape class synthesis (for `scan { }` / `generate { }` recipes). + // ------------------------------------------------------------------ + + /** + * True when the recipe trailing lambda declares a `scan { }` or bare + * `generate { }` phase. Inspects only the block's top-level statements (never + * user code inside phase lambdas), walking each dispatch-receiver chain to a + * `RecipeBuilder.scan` / `.generate` head. + */ + private fun recipeBlockDeclaresScanOrGenerate(recipeBlock: IrFunctionExpression): Boolean { + val stmts = (recipeBlock.function.body as? IrBlockBody)?.statements ?: return false + for (stmt in stmts) { + // `Scan.generate` returns Scan (non-Unit), so K2 wraps the + // statement in IMPLICIT_COERCION_TO_UNIT; peel it to reach the call. + val rawStmt = (stmt as? IrReturn)?.value ?: stmt + val unwrapped = if (rawStmt is IrTypeOperatorCall && + rawStmt.operator == IrTypeOperator.IMPLICIT_COERCION_TO_UNIT + ) { + rawStmt.argument + } else { + rawStmt + } + var cursor: IrCall? = unwrapped as? IrCall + while (cursor != null) { + when (cursor.symbol.owner.kotlinFqName.asString()) { + RECIPE_BUILDER_SCAN_FQN, RECIPE_BUILDER_GENERATE_FQN -> return true + } + cursor = cursor.dispatchReceiver as? IrCall + } + } + return false + } + + /** + * Synthesize a field-less `$KtRecipe` extending `ScanningRecipe` + * for a scan/generate recipe, overriding all four lifecycle methods to + * delegate to the `org.openrewrite.buildImperative*` runtime helpers. Unlike + * [buildImperativeRecipeClass] (plain `Recipe`, `getVisitor()` only), this + * preserves every phase. + */ + private fun buildScanningRecipeClass( + ctx: RecipeIrGenContext, + parentFile: IrFile, + propertyName: Name, + metadata: RecipeMetadata, + recipeBlock: IrFunctionExpression, + ): IrClass { + val anyType = ctx.pluginContext.irBuiltIns.anyType + val cls = ctx.pluginContext.irFactory.buildClass { + name = Name.identifier("${propertyName.asString()}\$KtRecipe") + kind = ClassKind.CLASS + modality = Modality.FINAL + visibility = DescriptorVisibilities.PUBLIC + } + cls.parent = parentFile + cls.createThisReceiverParameter() + cls.superTypes = listOf(ctx.scanningRecipeClassSymbol!!.typeWith(anyType)) + + cls.addConstructor { + isPrimary = true + returnType = cls.symbol.defaultType + visibility = DescriptorVisibilities.PUBLIC + }.apply { + body = DeclarationIrBuilder(ctx.pluginContext, symbol).irBlockBody { + +irDelegatingConstructorCall(ctx.scanningRecipeNoArgCtorSymbol!!.owner) + } + } + + addMetadataOverrides(cls, ctx, metadata) + addScanningLifecycleOverrides(cls, ctx, recipeBlock, anyType) + return cls + } + + private fun addScanningLifecycleOverrides( + cls: IrClass, + ctx: RecipeIrGenContext, + recipeBlock: IrFunctionExpression, + anyType: IrType, + ) { + val getInitialValue = ctx.scanningGetInitialValue!! + val getScanner = ctx.scanningGetScanner!! + val getVisitor = ctx.scanningGetVisitor!! + val generate = ctx.scanningGenerate!! + + cls.addFunction( + name = "getInitialValue", + returnType = anyType, + modality = Modality.OPEN, + visibility = DescriptorVisibilities.PUBLIC, + ).apply { + val ctxParam = addValueParameter("ctx", getInitialValue.valueParameters[0].type) + overriddenSymbols = listOf(getInitialValue.symbol) + body = DeclarationIrBuilder(ctx.pluginContext, symbol).irBlockBody { + val call = irCall(ctx.buildImperativeInitialValueSymbol!!, type = anyType) + call.arguments[0] = irGet(ctxParam) + call.arguments[1] = recipeBlock.deepCopyWithSymbols(initialParent = this@apply) + +irReturn(call) + } + } + + cls.addFunction( + name = "getScanner", + returnType = getScanner.returnType, + modality = Modality.OPEN, + visibility = DescriptorVisibilities.PUBLIC, + ).apply { + val accParam = addValueParameter("acc", anyType) + overriddenSymbols = listOf(getScanner.symbol) + body = DeclarationIrBuilder(ctx.pluginContext, symbol).irBlockBody { + val call = irCall(ctx.buildImperativeScannerSymbol!!, type = getScanner.returnType) + call.arguments[0] = irGet(accParam) + call.arguments[1] = recipeBlock.deepCopyWithSymbols(initialParent = this@apply) + +irReturn(call) + } + } + + cls.addFunction( + name = "getVisitor", + returnType = getVisitor.returnType, + modality = Modality.OPEN, + visibility = DescriptorVisibilities.PUBLIC, + ).apply { + val accParam = addValueParameter("acc", anyType) + overriddenSymbols = listOf(getVisitor.symbol) + body = DeclarationIrBuilder(ctx.pluginContext, symbol).irBlockBody { + val call = irCall(ctx.buildImperativeEditVisitorSymbol!!, type = getVisitor.returnType) + call.arguments[0] = irGet(accParam) + call.arguments[1] = recipeBlock.deepCopyWithSymbols(initialParent = this@apply) + +irReturn(call) + } + } + + cls.addFunction( + name = "generate", + returnType = generate.returnType, + modality = Modality.OPEN, + visibility = DescriptorVisibilities.PUBLIC, + ).apply { + val accParam = addValueParameter("acc", anyType) + val generatedParam = addValueParameter("generatedInThisCycle", generate.valueParameters[1].type) + val ctxParam = addValueParameter("ctx", generate.valueParameters[2].type) + overriddenSymbols = listOf(generate.symbol) + body = DeclarationIrBuilder(ctx.pluginContext, symbol).irBlockBody { + val call = irCall(ctx.buildImperativeGenerateSymbol!!, type = generate.returnType) + call.arguments[0] = irGet(accParam) + call.arguments[1] = irGet(generatedParam) + call.arguments[2] = irGet(ctxParam) + call.arguments[3] = recipeBlock.deepCopyWithSymbols(initialParent = this@apply) + +irReturn(call) + } + } + } + // ------------------------------------------------------------------ // Composite-shape class synthesis (for `recipes(...)` properties). // ------------------------------------------------------------------ diff --git a/rewrite-kotlin/src/test/kotlin/org/openrewrite/kotlin/recipe/RecipePluginScanningTest.kt b/rewrite-kotlin/src/test/kotlin/org/openrewrite/kotlin/recipe/RecipePluginScanningTest.kt new file mode 100644 index 00000000000..2159c32d1d3 --- /dev/null +++ b/rewrite-kotlin/src/test/kotlin/org/openrewrite/kotlin/recipe/RecipePluginScanningTest.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +package org.openrewrite.kotlin.recipe + +import org.junit.jupiter.api.Test +import org.openrewrite.Recipe +import org.openrewrite.ScanningRecipe +import org.openrewrite.test.RewriteTest +import org.openrewrite.kotlin.Assertions.kotlin +import org.openrewrite.test.SourceSpecs.text +import org.assertj.core.api.Assertions.assertThat + +/** + * End-to-end coverage for imperative DSL recipes that declare `scan { }` / + * `generate { }` phases. These shapes are NOT handled by the declarative + * `rewrite { } to { }` IR path — they route through the imperative-recipe + * synthesis, which must preserve every phase (`getInitialValue` / `getScanner` + * / `getVisitor` / `generate`) for the recipe to have any effect. + * + * Companion to `RecipePluginRewriteTest` (declarative shapes) and + * `RecipeDslSurfaceTest` (pure-runtime, no plugin). + */ +class RecipePluginScanningTest : RewriteTest { + + @Test + fun `scan then generate emits a source file`() { + val r = loadCompiledRecipe( + source = """ + import org.openrewrite.recipe + import org.openrewrite.Tree + import org.openrewrite.text.PlainText + import java.nio.file.Paths + + val InventoryKotlinClasses = recipe( + displayName = "Inventory Kotlin class declarations", + description = "..." + ) { + scan(mutableSetOf()) { classNames -> + kotlin { + visitClassDeclaration { cd -> + classNames.add(cd.name.simpleName) + cd + } + } + }.generate { classNames -> + if (classNames.isEmpty()) return@generate emptyList() + val inventory = classNames.sorted().joinToString("\n") + listOf( + PlainText.builder() + .id(Tree.randomId()) + .sourcePath(Paths.get("kotlin-classes.txt")) + .text(inventory) + .build(), + ) + } + } + """.trimIndent(), + propertyName = "InventoryKotlinClasses", + ) + rewriteRun( + // Noop-scanner generator re-emits each cycle, so cap at one cycle. + { spec -> spec.recipe(r).validateRecipeSerialization(false).cycles(1).expectedCyclesThatMakeChanges(1) }, + kotlin( + """ + class Alpha + class Beta + """.trimIndent(), + ), + text( + null, + """ + Alpha + Beta + """.trimIndent(), + { spec -> spec.path("kotlin-classes.txt") }, + ), + ) + } + + @Test + fun `scan then edit consumes the populated accumulator`() { + // The edit must see what the scanner populated, not a fresh empty acc. + val r = loadCompiledRecipe( + source = """ + import org.openrewrite.recipe + + val TagWhenMultipleClasses = recipe( + displayName = "Suffix ! when the file has >1 class", + description = "..." + ) { + scan(mutableSetOf()) { classNames -> + kotlin { + visitClassDeclaration { cd -> + classNames.add(cd.name.simpleName) + cd + } + } + }.edit { classNames -> + kotlin { + visitClassDeclaration { cd -> + if (classNames.size > 1 && !cd.simpleName.endsWith("!")) + cd.withName(cd.name.withSimpleName(cd.simpleName + "!")) + else cd + } + } + } + } + """.trimIndent(), + propertyName = "TagWhenMultipleClasses", + ) + rewriteRun( + { spec -> spec.recipe(r).validateRecipeSerialization(false) }, + kotlin( + """ + class Alpha + class Beta + """.trimIndent(), + """ + class Alpha! + class Beta! + """.trimIndent(), + ), + ) + } + + @Test + fun `bare generate emits a source file`() { + val r = loadCompiledRecipe( + source = """ + import org.openrewrite.recipe + import org.openrewrite.Tree + import org.openrewrite.text.PlainText + import java.nio.file.Paths + + val EmitMarker = recipe( + displayName = "Emit a marker file", + description = "..." + ) { + generate { + listOf( + PlainText.builder() + .id(Tree.randomId()) + .sourcePath(Paths.get("marker.txt")) + .text("generated") + .build(), + ) + } + } + """.trimIndent(), + propertyName = "EmitMarker", + ) + rewriteRun( + { spec -> spec.recipe(r).validateRecipeSerialization(false).cycles(1).expectedCyclesThatMakeChanges(1) }, + text( + null, + "generated", + { spec -> spec.path("marker.txt") }, + ), + ) + } + + @Test + fun `scanning recipe synthesizes a ScanningRecipe subclass`() { + val r = loadCompiledRecipe( + source = """ + import org.openrewrite.recipe + import org.openrewrite.Tree + import org.openrewrite.text.PlainText + import java.nio.file.Paths + + val EmitMarker = recipe("Emit", "...") { + generate { + listOf(PlainText.builder().id(Tree.randomId()) + .sourcePath(Paths.get("marker.txt")).text("x").build()) + } + } + """.trimIndent(), + propertyName = "EmitMarker", + ) + assertThat(r).isInstanceOf(ScanningRecipe::class.java) + assertThat(r::class.java.declaredFields).isEmpty() + } + + private fun loadCompiledRecipe(source: String, propertyName: String, packageName: String = ""): Recipe { + val result = RecipePluginCompileFixture.compile(source) + check(result.exitOk()) { "compile failed:\n${result.messages}" } + val topLevelClass = result.classLoader.loadClass( + if (packageName.isEmpty()) "RecipesKt" else "$packageName.RecipesKt" + ) + val getter = topLevelClass.getDeclaredMethod("get" + propertyName.replaceFirstChar { it.uppercase() }) + getter.isAccessible = true + return getter.invoke(null) as Recipe + } +}