/*
* 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 {Recipe} from "../recipe";
import {ExecutionContext} from "../execution";
import {noopVisitor, TreeVisitor} from "../visitor";
import {Parser} from "../parser";
import {TreePrinters} from "../print";
import {SourceFile} from "../tree";
import {Result, scheduleRun} from "../run";
import {SnowflakeId} from "@akashrajpurohit/snowflake-id";
import {mapAsync, trimIndent} from "../util";
import {ParseErrorKind} from "../parse-error";
import {MarkersKind, ParseExceptionResult} from "../markers";
import {JavaScriptVisitor} from "../javascript";
import {J} from "../java";
export interface SourceSpec {
kind: string,
before: string | null,
after?: AfterRecipeText
path?: string,
parser: (ctx: ExecutionContext) => Parser,
beforeRecipe?: (sourceFile: T) => T | void | Promise | Promise,
afterRecipe?: (sourceFile: T) => T | void | Promise | Promise,
ext: string
}
export class RecipeSpec {
checkParsePrintIdempotence: boolean = true
allowEmptyDiff: boolean = false
recipe: Recipe = new NoopRecipe()
/**
* Used for both parsing and recipe execution unless an alternative recipe execution context is set with
* recipeExecutionContext.
*/
executionContext: ExecutionContext = new ExecutionContext();
/**
* If not specified, will share executionContext instance with the parsing phase.
*/
recipeExecutionContext: ExecutionContext = this.executionContext;
private dataTableAssertions: { [key: string]: (rows: any[]) => void } = {}
dataTable(name: string, allRows: (rows: Row[]) => void) {
this.dataTableAssertions[name] = allRows;
}
async rewriteRun(...sourceSpecs: (SourceSpec | Generator, void, unknown> | AsyncGenerator, void, unknown>)[]): Promise {
// Flatten generators into a list of sourceSpecs
const flattenedSpecs: SourceSpec[] = [];
for (const specOrGenerator of sourceSpecs) {
if (specOrGenerator && typeof (specOrGenerator as any).next === 'function') {
// Check if it's an async generator
if (typeof (specOrGenerator as any)[Symbol.asyncIterator] === 'function') {
for await (const spec of specOrGenerator as AsyncGenerator, void, unknown>) {
flattenedSpecs.push(spec);
}
} else {
// Sync generator
for (const spec of specOrGenerator as Generator, void, unknown>) {
flattenedSpecs.push(spec);
}
}
} else {
flattenedSpecs.push(specOrGenerator as SourceSpec);
}
}
const specsByKind = flattenedSpecs.reduce((groups, spec) => {
const kind = spec.kind;
if (!groups[kind]) {
groups[kind] = [];
}
groups[kind].push(spec);
return groups;
}, {} as { [kind: string]: SourceSpec[] });
const allParsed: [SourceSpec, SourceFile][] = [];
for (const kind in specsByKind) {
const specs = specsByKind[kind];
const parsed = await this.parse(specs);
await this.expectNoParseFailures(parsed);
await this.expectWhitespaceNotToContainNonwhitespaceCharacters(parsed);
this.checkParsePrintIdempotence && await this.expectParsePrintIdempotence(parsed);
allParsed.push(...parsed);
}
// Apply beforeRecipe hooks (after idempotence check, before recipe execution)
for (let i = 0; i < allParsed.length; i++) {
const [spec, sourceFile] = allParsed[i];
if (spec.beforeRecipe) {
const b = spec.beforeRecipe(sourceFile);
if (b !== undefined) {
if (b instanceof Promise) {
const mapped = await b;
if (mapped === undefined) {
throw new Error("Expected beforeRecipe to return a SourceFile, but got undefined. Did you forget a return statement?");
}
allParsed[i] = [spec, mapped];
} else {
allParsed[i] = [spec, b as SourceFile];
}
}
}
}
const changeset = (await scheduleRun(this.recipe,
allParsed.map(([_, sourceFile]) => sourceFile),
this.recipeExecutionContext)).changeset;
await this.expectResultsToMatchAfter(flattenedSpecs, changeset, allParsed);
await this.expectGeneratedFiles(flattenedSpecs, changeset);
// for (const [name, assertion] of Object.entries(this.dataTableAssertions)) {
// assertion(getRows(name, this.recipeExecutionContext));
// }
}
private async expectNoParseFailures(parsed: [SourceSpec, SourceFile][]) {
for (const [_, sourceFile] of parsed) {
if (sourceFile.kind === ParseErrorKind) {
throw new Error("Parsed source contains a ParseError: " +
(sourceFile.markers.markers.find(m => m.kind === MarkersKind.ParseExceptionResult)! as ParseExceptionResult).message);
}
}
}
private async expectParsePrintIdempotence(parsed: [SourceSpec, SourceFile][]) {
for (const [spec, sourceFile] of parsed) {
const beforeSource = dedent(spec.before!);
const printed = await TreePrinters.print(sourceFile);
if (printed !== beforeSource) {
throw new Error(
`Parse-print idempotence check failed.\n` +
`Expected:\n${beforeSource}\n\nActual:\n${printed}`
);
}
}
}
private async expectResultsToMatchAfter(specs: SourceSpec[], changeset: Result[], parsed: [SourceSpec, SourceFile][]) {
for (const spec of specs) {
const matchingSpec = parsed.find(([s, _]) => s === spec);
const result = changeset.find(c => {
if (c.before) {
return c.before === matchingSpec![1];
} else if (c.after) {
const matchingSpec = specs.find(s => s.path === c.after!.sourcePath);
return !!matchingSpec;
}
});
const after = result?.after;
if (!spec.after) {
if (after && after !== result?.before) {
const actual = await TreePrinters.print(after);
const expected = dedent(spec.before!);
if (actual === expected) {
if (!this.allowEmptyDiff) {
throw new Error(
"An empty diff was generated. The recipe incorrectly " +
"changed the AST without changing the printed output."
);
}
} else {
throw new Error(
"Expected no change but recipe modified the file.\n" +
`Expected:\n${expected}\n\nActual:\n${actual}`
);
}
}
if (spec.afterRecipe) {
await spec.afterRecipe(matchingSpec![1]);
}
} else {
await this.expectAfter(spec, after);
}
}
}
private async expectGeneratedFiles(specs: SourceSpec[], changeset: Result[]) {
for (const spec of specs) {
if (spec.before) {
continue;
}
if (!spec.after) {
throw new Error("Expected after to be defined if before is undefined.");
}
const after = changeset.find(c => c.after?.sourcePath === spec.path);
await this.expectAfter(spec, after?.after);
}
}
private async expectAfter(spec: SourceSpec, after?: SourceFile) {
if (!after) {
throw new Error('Expected for recipe to have produced a change for file:\n' + trimIndent(spec.before))
}
await new ValidateWhitespaceVisitor().visit(after, this.executionContext);
const actualAfter = await TreePrinters.print(after);
const afterSource = typeof spec.after === "function" ?
(spec.after as (actual: string) => string)(actualAfter) : spec.after as string;
if (actualAfter !== afterSource) {
throw new Error(
`Recipe output does not match expected.\n` +
`Expected:\n${afterSource}\n\nActual:\n${actualAfter}`
);
}
if (spec.afterRecipe) {
await spec.afterRecipe(after);
}
}
/**
* Parse the whole group together so the sources can reference once another.
*/
private async parse(specs: SourceSpec[]): Promise<[SourceSpec, SourceFile][]> {
let snowflake = SnowflakeId();
const before: [SourceSpec, { text: string, sourcePath: string }][] = [];
for (const spec of specs) {
if (spec.before) {
const sourcePath = spec.path || `${snowflake.generate()}.${spec.ext}`;
before.push([spec, {text: dedent(spec.before), sourcePath: sourcePath}]);
}
}
const parser = specs[0].parser(this.executionContext);
const parsed: SourceFile[] = [];
for await (const sourceFile of parser.parse(...before.map(([_, parserInput]) => parserInput))) {
parsed.push(sourceFile);
}
return before.map(([spec, _], i): [SourceSpec, SourceFile] => [spec, parsed[i]]);
}
private async expectWhitespaceNotToContainNonwhitespaceCharacters(parsed: [SourceSpec, SourceFile][]) {
const validator = new ValidateWhitespaceVisitor();
for (const [_, sourceFile] of parsed) {
await validator.visit(sourceFile, this.executionContext);
}
}
}
class ValidateWhitespaceVisitor extends JavaScriptVisitor {
public override async visitSpace(space: J.Space, p: ExecutionContext): Promise {
const ret = super.visitSpace(space, p);
if (!/^\s*$/.test(space.whitespace)) {
throw new Error(
`Whitespace contains non-whitespace characters: ${JSON.stringify(space.whitespace)}`
);
}
return ret;
}
}
class NoopRecipe extends Recipe {
name = "org.openrewrite.noop";
displayName = "Do nothing";
description = "Default no-op test, does nothing.";
async editor(): Promise> {
return noopVisitor();
}
}
export type AfterRecipeText = string | ((actual: string) => string | undefined) | undefined | null;
/**
* Simple dedent implementation that removes common leading whitespace from each line.
*
* Behavior:
* - Removes ONE leading newline if present (for template string ergonomics)
* - Preserves trailing newlines (important for testing formatters like Prettier)
* - For lines with content: removes common indentation
* - For lines with only whitespace: removes common indentation, preserving remaining spaces
*
* Examples:
* - `\n code` → `code` (single leading newline removed)
* - `\n\n code` → `\ncode` (first newline removed, second preserved)
* - ` code\n` → `code\n` (trailing newline preserved)
* - ` code\n\n` → `code\n\n` (trailing newlines preserved)
*/
export function dedent(s: string): string {
if (!s) return s;
// Remove single leading newline for ergonomics
const start = s.charCodeAt(0) === 10 ? 1 : 0; // 10 = '\n'
if (start >= s.length) return '';
const str = start > 0 ? s.slice(start) : s;
const lines = str.split('\n');
// Always consider all lines for minIndent calculation
// If first line has content at column 0, minIndent will be 0 and no dedenting happens
const startLine = 0;
// Find minimum indentation
let minIndent = Infinity;
for (let i = startLine; i < lines.length; i++) {
const line = lines[i];
let indent = 0;
for (let j = 0; j < line.length; j++) {
const ch = line.charCodeAt(j);
if (ch === 32 || ch === 9) { // ' ' or '\t'
indent++;
} else {
// Found non-whitespace, update minIndent
if (indent < minIndent) minIndent = indent;
break;
}
}
}
// If all lines are empty or no indentation
if (minIndent === Infinity || minIndent === 0) {
return lines.join('\n');
}
// Remove common indentation from all lines
return lines.map(line =>
line.length >= minIndent ? line.slice(minIndent) : ''
).join('\n');
}
export function dedentAfter(s?: AfterRecipeText): AfterRecipeText {
if (s !== null) {
if (s === undefined) {
return undefined;
}
if (typeof s === "function") {
return (actual: string): string | undefined => {
const raw = s(actual);
return raw;
};
}
return () => dedent(s);
}
return null;
}
export class AdHocRecipe extends Recipe {
name = "org.openrewrite.adhoc"
displayName = "ad-hoc"
description = "ad-hoc."
constructor(private visitor: TreeVisitor) {
super();
}
async editor(): Promise> {
return this.visitor;
}
}
export function fromVisitor(visitor: TreeVisitor): Recipe {
return new AdHocRecipe(visitor);
}