/* * Copyright 2025 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* https://docs.moderne.io/licensing/moderne-source-available-license *

* 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. */ import * as rpc from "vscode-jsonrpc/node"; import {MessageConnection} from "vscode-jsonrpc/node"; import {Recipe, RecipeDescriptor, ScanningRecipe} from "../../recipe"; import {SnowflakeId} from "@akashrajpurohit/snowflake-id"; import {Check, CheckArg, CompositePrecondition, RecipeRef} from "../../preconditions"; import {RpcRecipe} from "../recipe"; import {TreeVisitor} from "../../visitor"; import {ExecutionContext} from "../../execution"; import {withMetrics} from "./metrics"; import {RecipeMarketplace} from "../../marketplace"; export class PrepareRecipe { constructor(private readonly id: string, private readonly options?: any) { } static handle(connection: MessageConnection, marketplace: RecipeMarketplace, preparedRecipes: Map, metricsCsv?: string) { const snowflake = SnowflakeId(); connection.onRequest( new rpc.RequestType("PrepareRecipe"), withMetrics( "PrepareRecipe", metricsCsv, (context) => async (request) => { context.target = request.id; const recipeCtor = marketplace.findRecipe(request.id); if (!recipeCtor) { // A miss means the host owns this recipe (e.g. a sub-recipe that delegates to // a Java recipe, produced by prepareJavaRecipe — an RpcRecipe hosted on the peer // with no no-arg constructor to register here). Tell the host to resolve the id // locally via delegatesTo rather than failing with "Could not find recipe ...". const id = snowflake.generate(); return { id: id, descriptor: PrepareRecipe.delegateDescriptor(request.id), editVisitor: `edit:${id}`, editPreconditions: [], scanPreconditions: [], delegatesTo: { recipeName: request.id, options: request.options ?? {} } }; } if (!recipeCtor[1]) { throw new Error(`Recipe ${request.id} was installed without a constructor`); } return await PrepareRecipe.prepareInstance(new recipeCtor[1](request.options), snowflake, preparedRecipes, marketplace); } ) ); } /** * Minimal stand-in descriptor for a recipe the host will resolve locally via * {@link PrepareRecipeResponse.delegatesTo}. The host reads only `delegatesTo` for * delegated recipes and ignores this descriptor, but the response type requires one. */ private static delegateDescriptor(name: string): RecipeDescriptor { return { name: name, displayName: name, instanceName: name, description: "", tags: [], estimatedEffortPerOccurrence: 5, options: [], preconditions: [], recipeList: [], dataTables: [], maintainers: [], contributors: [], examples: [] }; } /** * Prepares a recipe instance and recursively its whole child tree, registering every node in * {@code preparedRecipes} and returning the prepared response with {@code recipeList} populated, * so the host builds the tree locally instead of a PrepareRecipe round-trip per child. * * Mirrors the C# server's PrepareInstance. Required options are validated as each node is * prepared (covering the whole tree). A child hosted on the peer (an {@link RpcRecipe}, e.g. from * prepareJavaRecipe) carries only {@code delegatesTo} for the host to resolve locally; every other * child carries its own {@code recipeList}. Delegating recipes forward validation to the recipe * they delegate to, so they are not validated here. */ private static async prepareInstance(recipe: Recipe, snowflake: ReturnType, preparedRecipes: Map, marketplace: RecipeMarketplace): Promise { const id = snowflake.generate(); const editPreconditions: Precondition[] = []; recipe = await PrepareRecipe.optimizePreconditions(recipe, "edit", editPreconditions); const scanPreconditions: Precondition[] = []; recipe = await PrepareRecipe.optimizePreconditions(recipe, "scan", scanPreconditions); preparedRecipes.set(id, recipe); const descriptor = await recipe.descriptor(); const isDelegating = 'javaRecipeName' in recipe; if (!isDelegating) { for (const option of descriptor.options) { if ((option.required ?? true) && option.value == null) { throw new Error(`Missing required option \`${option.name}\` for recipe \`${descriptor.name}\`.`); } } } const response: PrepareRecipeResponse = { id: id, descriptor: descriptor, editVisitor: `edit:${id}`, editPreconditions: editPreconditions, scanVisitor: recipe instanceof ScanningRecipe ? `scan:${id}` : undefined, scanPreconditions: scanPreconditions }; if (isDelegating) { response.delegatesTo = { recipeName: (recipe as any).javaRecipeName, options: (recipe as any).delegatesToOptions ?? {} }; return response; } const childResponses: PrepareRecipeResponse[] = []; for (const child of await recipe.recipeList()) { if (child instanceof RpcRecipe) { // Hosted on the peer: emit delegatesTo (its name + the options its parent set) so the // host resolves it locally, matching how the by-name fallback used to resolve it. const childId = snowflake.generate(); const childDescriptor = await child.descriptor(); const options: Record = {}; for (const option of childDescriptor.options) { if (option.value != null) { options[option.name] = option.value; } } childResponses.push({ id: childId, descriptor: PrepareRecipe.delegateDescriptor(child.name), editVisitor: `edit:${childId}`, editPreconditions: [], scanPreconditions: [], delegatesTo: {recipeName: child.name, options} }); } else { // Register a child that was instantiated in recipeList() but never installed, so a peer // that re-prepares children by name (rather than consuming recipeList) can still find it. if (!marketplace.findRecipe(child.name)) { await marketplace.install(child.constructor as any, []); } childResponses.push(await PrepareRecipe.prepareInstance(child, snowflake, preparedRecipes, marketplace)); } } response.recipeList = childResponses; return response; } /** * For preconditions that can be evaluated on the remote peer, let the remote peer * evaluate them and know that we will only have to do the visit work if the * precondition passes. */ private static async optimizePreconditions(recipe: Recipe, phase: "edit" | "scan", preconditions: Precondition[]): Promise { let visitor: TreeVisitor; if (phase === "edit") { visitor = await recipe.editor(); } else if (phase === "scan") { if (recipe instanceof ScanningRecipe) { visitor = await recipe.scanner(undefined); } else { return recipe; } } if (visitor! instanceof Check) { const wireEntry = this.conditionWireEntry(visitor.check, phase); if (wireEntry) { preconditions.push(wireEntry); recipe = Object.assign( Object.create(Object.getPrototypeOf(recipe)), recipe, phase === "edit" ? { async editor(): Promise> { return visitor.v; } } : { async scanner(acc: any): Promise> { const checkVisitor = await (recipe as ScanningRecipe).scanner(acc); return (checkVisitor as Check).v; } } ) } await this.visitorTypePrecondition(preconditions, visitor.v); } else { await this.visitorTypePrecondition(preconditions, visitor!); } return recipe; } /** * Translate a precondition condition (operand) to a wire entry. * * Mirrors the Java {@code PrepareRecipeResponse.Precondition} schema: * leaves carry {@code visitorName} (+ optional options); composites * carry {@code op} ({@code "or"}/{@code "and"}/{@code "not"}) and a * nested {@code operands} list. Returns {@code undefined} when the * condition can't be serialized — the caller leaves the wrapper * intact so the gate runs in-process as a fallback. */ private static conditionWireEntry(condition: CheckArg, phase: "edit" | "scan"): Precondition | undefined { if (condition instanceof CompositePrecondition) { const operands: Precondition[] = []; for (const operand of condition.operands) { const entry = this.conditionWireEntry(operand, phase); if (entry === undefined) { return undefined; } operands.push(entry); } return {op: condition.op, operands}; } // Common case: helpers like usesMethod / usesType return a lightweight // RecipeRef so the recipe author can declare a precondition without // firing an RPC at editor() time. The Java host's // PreparedRecipeCache.instantiateVisitor constructs the named recipe // via Jackson and uses its visitor. if (condition instanceof RecipeRef) { return {visitorName: condition.recipeName, visitorOptions: {...condition.options}}; } if (condition instanceof RpcRecipe) { return {visitorName: phase === "edit" ? condition.editVisitor : condition.scanVisitor!}; } return undefined; } private static async visitorTypePrecondition(preconditions: Precondition[], v: TreeVisitor): Promise { let treeType: string | undefined; // Use dynamic import to defer loading and avoid circular dependencies const {JsonVisitor} = await import("../../json/index.js"); const {JavaScriptVisitor} = await import("../../javascript/index.js"); const {JavaVisitor} = await import("../../java/index.js"); const {PlainTextVisitor} = await import("../../text/index.js"); const {YamlVisitor} = await import("../../yaml/index.js"); if (v instanceof JsonVisitor) { treeType = "org.openrewrite.json.tree.Json"; } else if (v instanceof JavaScriptVisitor) { // Order is important here! JavaScriptVisitor is a subclass of JavaVisitor // and so must appear first in these conditional statements treeType = "org.openrewrite.javascript.tree.JS"; } else if (v instanceof JavaVisitor) { treeType = "org.openrewrite.java.tree.J"; } else if (v instanceof PlainTextVisitor) { treeType = "org.openrewrite.text.PlainText"; } else if (v instanceof YamlVisitor) { treeType = "org.openrewrite.yaml.tree.Yaml"; } if (treeType) { preconditions.push({ visitorName: "org.openrewrite.rpc.internal.FindTreesOfType", visitorOptions: {type: treeType} }); } return preconditions; } } export interface DelegatesTo { recipeName: string options: Record } export interface PrepareRecipeResponse { id: string descriptor: RecipeDescriptor editVisitor: string editPreconditions: Precondition[] scanVisitor?: string scanPreconditions: Precondition[] delegatesTo?: DelegatesTo /** * The prepared child recipes of a composite. When present, the host builds the recipe tree * locally from these instead of re-preparing each child by name (the whole-tree optimization). */ recipeList?: PrepareRecipeResponse[] } /** * Either a leaf (a single visitor identified by {@code visitorName} + * optional {@code visitorOptions}) or a composite of nested preconditions * joined by {@code op} ({@code "or"} / {@code "and"} / {@code "not"}). * * When {@code op} is undefined the entry is a leaf and {@code visitorName} * is required; when {@code op} is set, {@code operands} carries the * children and the visitor fields are ignored. The composite form mirrors * Java's {@code Preconditions.or}/{@code and}/{@code not} so remote * languages can express the same gate shapes the Java side does. */ export interface Precondition { visitorName?: string visitorOptions?: {} op?: "or" | "and" | "not" operands?: Precondition[] }