/* * 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 {ExecutionContext} from "./execution"; import {rootCursor, SourceFile} from "./tree"; import {createTwoFilesPatch} from "diff"; import {TreePrinters} from "./print"; import {Recipe, ScanningRecipe} from "./recipe"; export interface RecipeRun { changeset: Result[] } export class Result { constructor(public readonly before?: SourceFile, public readonly after?: SourceFile) { } async diff(): Promise { return createTwoFilesPatch( this.before?.sourcePath ?? "", this.after?.sourcePath ?? "", this.before ? await TreePrinters.print(this.before) : "", this.after ? await TreePrinters.print(this.after) : "", "", "", {context: 3} ); } } /** * Recipes commonly instantiate their sub-recipes inside `recipeList()`, so calling it more * than once yields different instances. A scanning sub-recipe would then hold a different * accumulator in every phase and for every source file. Resolving each recipe list once per * run keeps sub-recipe identity, and therefore accumulator identity, stable. */ type RecipeLists = WeakMap; async function subRecipes(recipe: Recipe, recipeLists: RecipeLists): Promise { let resolved = recipeLists.get(recipe); if (resolved === undefined) { resolved = await recipe.recipeList(); recipeLists.set(recipe, resolved); } return resolved; } async function hasScanningRecipe(recipe: Recipe, recipeLists: RecipeLists): Promise { if (recipe instanceof ScanningRecipe) return true; for (const item of (await subRecipes(recipe, recipeLists))) { if (await hasScanningRecipe(item, recipeLists)) return true; } return false; } async function recurseRecipeList(recipe: Recipe, initial: T, recipeLists: RecipeLists, fn: (recipe: Recipe, t: T) => Promise): Promise { let t: T | undefined = await fn(recipe, initial); for (const subRecipe of await subRecipes(recipe, recipeLists)) { if (t === undefined) { return undefined; } t = await recurseRecipeList(subRecipe, t, recipeLists, fn); } return t; } async function recursiveOnComplete(recipe: Recipe, ctx: ExecutionContext, recipeLists: RecipeLists): Promise { await recipe.onComplete(ctx); for (const subRecipe of await subRecipes(recipe, recipeLists)) { await recursiveOnComplete(subRecipe, ctx, recipeLists); } } export async function scheduleRun(recipe: Recipe, before: SourceFile[], ctx: ExecutionContext): Promise { const changeset: Result[] = []; for await (const result of scheduleRunStreaming(recipe, before, ctx)) { changeset.push(result); } return { changeset }; } export type ProgressCallback = (phase: 'parsing' | 'scanning' | 'processing', current: number, total: number, sourcePath: string) => void; /** * Streaming version of scheduleRun that yields results as soon as each file is processed. * This allows callers to print diffs immediately and free memory earlier. * * Accepts either an array or an async iterable of source files. Files are processed * immediately as they're yielded from the iterable, avoiding the need to collect all * files into memory before starting work. * * For scanning recipes, each file is scanned immediately as it's pulled from the generator, * then stored for the edit phase. The scan phase completes before any results are yielded. * * @param onProgress Optional callback for progress updates during scanning and processing phases. */ export async function* scheduleRunStreaming( recipe: Recipe, before: SourceFile[] | AsyncIterable, ctx: ExecutionContext, onProgress?: ProgressCallback ): AsyncGenerator { const cursor = rootCursor(); const recipeLists: RecipeLists = new WeakMap(); const isScanning = await hasScanningRecipe(recipe, recipeLists); const knownTotal = Array.isArray(before) ? before.length : -1; // -1 = unknown total if (isScanning) { // For scanning recipes, pull files from the generator and scan them immediately. // Files are stored for the later edit phase. const files: SourceFile[] = []; // Phase 1: Pull files from generator and scan each immediately let scanCount = 0; for await (const b of before) { files.push(b); scanCount++; onProgress?.('scanning', scanCount, knownTotal, b.sourcePath); // Scan this file immediately. Scanning must not modify the tree, so the visit // result is discarded and the original file is passed on to the next recipe in // the list. Returning `undefined` here would abort traversal of the remainder // of the recipe list, leaving nested scanners unrun. await recurseRecipeList(recipe, b, recipeLists, async (recipe, b2) => { if (recipe instanceof ScanningRecipe) { await (await recipe.scanner(recipe.accumulator(cursor, ctx))).visit(b2, ctx, cursor); } return b2; }); } const totalFiles = files.length; // Phase 2: Collect generated files const generated = (await recurseRecipeList(recipe, [] as SourceFile[], recipeLists, async (recipe, generated) => { if (recipe instanceof ScanningRecipe) { generated.push(...await recipe.generate(recipe.accumulator(cursor, ctx), ctx)); } return generated; }))!; // Phase 3: Edit existing files and yield results immediately for (let i = 0; i < files.length; i++) { const b = files[i]; onProgress?.('processing', i + 1, totalFiles, b.sourcePath); const editedB = await recurseRecipeList(recipe, b, recipeLists, async (recipe, b2) => (await recipe.editor()).visit(b2, ctx, cursor)); // Always yield a result so the caller knows when each file is processed yield new Result(b, editedB !== b ? editedB : b); // Clear array entry to allow GC to free memory for this file (files as any)[i] = null; } // Phase 4: Edit generated files and yield results for (const g of generated) { const editedG = await recurseRecipeList(recipe, g, recipeLists, async (recipe, g2) => (await recipe.editor()).visit(g2, ctx, cursor)); if (editedG) { yield new Result(undefined, editedG); } } } else { // For non-scanning recipes, process files immediately as they come in let processCount = 0; for await (const b of before) { processCount++; onProgress?.('processing', processCount, knownTotal, b.sourcePath); const editedB = await recurseRecipeList(recipe, b, recipeLists, async (recipe, b2) => (await recipe.editor()).visit(b2, ctx, cursor)); // Always yield a result so the caller knows when each file is processed yield new Result(b, editedB !== b ? editedB : b); } } await recursiveOnComplete(recipe, ctx, recipeLists); }