@openrewrite/rewrite 8.96.4 → 8.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/execution.d.ts +2 -0
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +4 -0
- package/dist/execution.js.map +1 -1
- package/dist/heavyweight.d.ts +8 -0
- package/dist/heavyweight.d.ts.map +1 -0
- package/dist/heavyweight.js +45 -0
- package/dist/heavyweight.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/java/tree.d.ts +2 -1
- package/dist/java/tree.d.ts.map +1 -1
- package/dist/java/tree.js.map +1 -1
- package/dist/java/visitor.d.ts +5 -0
- package/dist/java/visitor.d.ts.map +1 -1
- package/dist/java/visitor.js +264 -217
- package/dist/java/visitor.js.map +1 -1
- package/dist/javascript/add-import.d.ts +2 -1
- package/dist/javascript/add-import.d.ts.map +1 -1
- package/dist/javascript/add-import.js +14 -2
- package/dist/javascript/add-import.js.map +1 -1
- package/dist/javascript/amd.d.ts.map +1 -1
- package/dist/javascript/amd.js +2 -1
- package/dist/javascript/amd.js.map +1 -1
- package/dist/javascript/cleanup/add-parse-int-radix.js +1 -1
- package/dist/javascript/cleanup/add-parse-int-radix.js.map +1 -1
- package/dist/javascript/dependency-workspace.d.ts +8 -2
- package/dist/javascript/dependency-workspace.d.ts.map +1 -1
- package/dist/javascript/dependency-workspace.js +36 -4
- package/dist/javascript/dependency-workspace.js.map +1 -1
- package/dist/javascript/format/format.d.ts +10 -0
- package/dist/javascript/format/format.d.ts.map +1 -1
- package/dist/javascript/format/format.js +10 -2
- package/dist/javascript/format/format.js.map +1 -1
- package/dist/javascript/format/tabs-and-indents-visitor.d.ts.map +1 -1
- package/dist/javascript/format/tabs-and-indents-visitor.js +4 -0
- package/dist/javascript/format/tabs-and-indents-visitor.js.map +1 -1
- package/dist/javascript/format/whitespace-reconciler.d.ts.map +1 -1
- package/dist/javascript/format/whitespace-reconciler.js +4 -0
- package/dist/javascript/format/whitespace-reconciler.js.map +1 -1
- package/dist/javascript/node-resolution-result.d.ts.map +1 -1
- package/dist/javascript/node-resolution-result.js +2 -0
- package/dist/javascript/node-resolution-result.js.map +1 -1
- package/dist/javascript/parser-utils.d.ts.map +1 -1
- package/dist/javascript/parser-utils.js +9 -6
- package/dist/javascript/parser-utils.js.map +1 -1
- package/dist/javascript/templating/engine.d.ts +1 -0
- package/dist/javascript/templating/engine.d.ts.map +1 -1
- package/dist/javascript/templating/engine.js +39 -10
- package/dist/javascript/templating/engine.js.map +1 -1
- package/dist/javascript/templating/pattern.d.ts.map +1 -1
- package/dist/javascript/templating/pattern.js +8 -0
- package/dist/javascript/templating/pattern.js.map +1 -1
- package/dist/javascript/templating/placeholder-replacement.d.ts +2 -0
- package/dist/javascript/templating/placeholder-replacement.d.ts.map +1 -1
- package/dist/javascript/templating/placeholder-replacement.js +37 -1
- package/dist/javascript/templating/placeholder-replacement.js.map +1 -1
- package/dist/javascript/visitor.d.ts.map +1 -1
- package/dist/javascript/visitor.js +153 -118
- package/dist/javascript/visitor.js.map +1 -1
- package/dist/recipe.d.ts +5 -0
- package/dist/recipe.d.ts.map +1 -1
- package/dist/recipe.js +5 -0
- package/dist/recipe.js.map +1 -1
- package/dist/rewrite-javascript-version.txt +1 -1
- package/dist/run.d.ts +7 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +40 -2
- package/dist/run.js.map +1 -1
- package/dist/test/accumulator-contents.d.ts +15 -0
- package/dist/test/accumulator-contents.d.ts.map +1 -0
- package/dist/test/accumulator-contents.js +185 -0
- package/dist/test/accumulator-contents.js.map +1 -0
- package/dist/test/empty-sources.d.ts +14 -0
- package/dist/test/empty-sources.d.ts.map +1 -0
- package/dist/test/empty-sources.js +98 -0
- package/dist/test/empty-sources.js.map +1 -0
- package/dist/test/formatting-scope.d.ts +18 -0
- package/dist/test/formatting-scope.d.ts.map +1 -0
- package/dist/test/formatting-scope.js +244 -0
- package/dist/test/formatting-scope.js.map +1 -0
- package/dist/test/probes.d.ts +25 -0
- package/dist/test/probes.d.ts.map +1 -0
- package/dist/test/probes.js +189 -0
- package/dist/test/probes.js.map +1 -0
- package/dist/test/rewrite-test.d.ts +27 -0
- package/dist/test/rewrite-test.d.ts.map +1 -1
- package/dist/test/rewrite-test.js +191 -21
- package/dist/test/rewrite-test.js.map +1 -1
- package/dist/test/tree-shapes.d.ts +15 -0
- package/dist/test/tree-shapes.d.ts.map +1 -0
- package/dist/test/tree-shapes.js +288 -0
- package/dist/test/tree-shapes.js.map +1 -0
- package/dist/test/unparsed-sources.d.ts +17 -0
- package/dist/test/unparsed-sources.d.ts.map +1 -0
- package/dist/test/unparsed-sources.js +113 -0
- package/dist/test/unparsed-sources.js.map +1 -0
- package/dist/tree.d.ts.map +1 -1
- package/dist/tree.js +2 -0
- package/dist/tree.js.map +1 -1
- package/dist/visitor.d.ts.map +1 -1
- package/dist/visitor.js +2 -0
- package/dist/visitor.js.map +1 -1
- package/package.json +1 -1
- package/src/execution.ts +6 -0
- package/src/heavyweight.ts +42 -0
- package/src/index.ts +1 -0
- package/src/java/tree.ts +2 -1
- package/src/java/visitor.ts +263 -216
- package/src/javascript/add-import.ts +11 -2
- package/src/javascript/amd.ts +4 -2
- package/src/javascript/cleanup/add-parse-int-radix.ts +2 -2
- package/src/javascript/dependency-workspace.ts +35 -3
- package/src/javascript/format/format.ts +18 -0
- package/src/javascript/format/tabs-and-indents-visitor.ts +4 -0
- package/src/javascript/format/whitespace-reconciler.ts +4 -0
- package/src/javascript/node-resolution-result.ts +3 -0
- package/src/javascript/parser-utils.ts +10 -6
- package/src/javascript/templating/engine.ts +41 -9
- package/src/javascript/templating/pattern.ts +10 -0
- package/src/javascript/templating/placeholder-replacement.ts +38 -1
- package/src/javascript/visitor.ts +166 -118
- package/src/recipe.ts +5 -0
- package/src/run.ts +30 -2
- package/src/test/accumulator-contents.ts +194 -0
- package/src/test/empty-sources.ts +99 -0
- package/src/test/formatting-scope.ts +231 -0
- package/src/test/probes.ts +156 -0
- package/src/test/rewrite-test.ts +177 -24
- package/src/test/tree-shapes.ts +332 -0
- package/src/test/unparsed-sources.ts +138 -0
- package/src/tree.ts +3 -0
- package/src/visitor.ts +3 -0
package/src/recipe.ts
CHANGED
|
@@ -175,6 +175,11 @@ export interface OptionDescriptor {
|
|
|
175
175
|
readonly type?: string
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* @typeParam P The type of the accumulator where scanning data is held until the editing phase. It stays in memory
|
|
180
|
+
* until the cycle ends, so it should hold values such as coordinates, paths and ids, not trees or other objects of a
|
|
181
|
+
* type given to registerHeavyweight.
|
|
182
|
+
*/
|
|
178
183
|
export abstract class ScanningRecipe<P> extends Recipe {
|
|
179
184
|
private readonly recipeAccMessage = Symbol("org.openrewrite.recipe.acc");
|
|
180
185
|
|
package/src/run.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import {ExecutionContext} from "./execution";
|
|
17
|
-
import {rootCursor, SourceFile} from "./tree";
|
|
17
|
+
import {Cursor, rootCursor, SourceFile} from "./tree";
|
|
18
18
|
import {createTwoFilesPatch} from "diff";
|
|
19
19
|
import {TreePrinters} from "./print";
|
|
20
20
|
import {Recipe, ScanningRecipe} from "./recipe";
|
|
@@ -83,6 +83,25 @@ async function recursiveOnComplete(recipe: Recipe, ctx: ExecutionContext, recipe
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Under this key a test harness puts an {@link AccumulatorListener} on the execution context, which the scheduler
|
|
88
|
+
* calls with the accumulator of each scanning recipe after the scanning phase and again after the editing phase.
|
|
89
|
+
*/
|
|
90
|
+
export const ACCUMULATOR_LISTENER = "org.openrewrite.test.accumulatorListener";
|
|
91
|
+
|
|
92
|
+
export type AccumulatorListener =
|
|
93
|
+
(accumulators: Map<ScanningRecipe<any>, any>, phase: "after scanning" | "after editing") => void;
|
|
94
|
+
|
|
95
|
+
async function accumulators(recipe: Recipe, recipeLists: RecipeLists, cursor: Cursor,
|
|
96
|
+
ctx: ExecutionContext): Promise<Map<ScanningRecipe<any>, any>> {
|
|
97
|
+
return (await recurseRecipeList(recipe, new Map<ScanningRecipe<any>, any>(), recipeLists, async (r, found) => {
|
|
98
|
+
if (r instanceof ScanningRecipe && !found.has(r)) {
|
|
99
|
+
found.set(r, r.accumulator(cursor, ctx));
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}))!;
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
export async function scheduleRun(recipe: Recipe, before: SourceFile[], ctx: ExecutionContext): Promise<RecipeRun> {
|
|
87
106
|
const changeset: Result[] = [];
|
|
88
107
|
for await (const result of scheduleRunStreaming(recipe, before, ctx)) {
|
|
@@ -135,13 +154,21 @@ export async function* scheduleRunStreaming(
|
|
|
135
154
|
// of the recipe list, leaving nested scanners unrun.
|
|
136
155
|
await recurseRecipeList(recipe, b, recipeLists, async (recipe, b2) => {
|
|
137
156
|
if (recipe instanceof ScanningRecipe) {
|
|
138
|
-
await (await recipe.scanner(recipe.accumulator(cursor, ctx))).visit(b2, ctx, cursor);
|
|
157
|
+
const scanned = await (await recipe.scanner(recipe.accumulator(cursor, ctx))).visit(b2, ctx, cursor);
|
|
158
|
+
if (scanned !== b2 && ctx.getBoolean(ExecutionContext.SCANNING_MUTATION_VALIDATION, false)) {
|
|
159
|
+
throw new Error("Record what the scanner finds in the accumulator, and make the edits in " +
|
|
160
|
+
`editorWithData(acc). ${recipe.name}'s scanner ${scanned ? "changed" : "deleted"} ` +
|
|
161
|
+
`${b2.sourcePath}, and the scanning phase discards edits. A scanner that reuses an ` +
|
|
162
|
+
"editing visitor opts out with spec.checkNoEditsInScanningPhase = false.");
|
|
163
|
+
}
|
|
139
164
|
}
|
|
140
165
|
return b2;
|
|
141
166
|
});
|
|
142
167
|
}
|
|
143
168
|
|
|
144
169
|
const totalFiles = files.length;
|
|
170
|
+
const listener: AccumulatorListener | undefined = ctx.messages[ACCUMULATOR_LISTENER];
|
|
171
|
+
listener?.(await accumulators(recipe, recipeLists, cursor, ctx), "after scanning");
|
|
145
172
|
|
|
146
173
|
// Phase 2: Collect generated files
|
|
147
174
|
const generated = (await recurseRecipeList(recipe, [] as SourceFile[], recipeLists, async (recipe, generated) => {
|
|
@@ -169,6 +196,7 @@ export async function* scheduleRunStreaming(
|
|
|
169
196
|
yield new Result(undefined, editedG);
|
|
170
197
|
}
|
|
171
198
|
}
|
|
199
|
+
listener?.(await accumulators(recipe, recipeLists, cursor, ctx), "after editing");
|
|
172
200
|
} else {
|
|
173
201
|
// For non-scanning recipes, process files immediately as they come in
|
|
174
202
|
let processCount = 0;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 the original author or authors.
|
|
3
|
+
* <p>
|
|
4
|
+
* Licensed under the Moderne Source Available License (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
* <p>
|
|
8
|
+
* https://docs.moderne.io/licensing/moderne-source-available-license
|
|
9
|
+
* <p>
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import {types} from "util";
|
|
17
|
+
import {AccumulatorListener} from "../run";
|
|
18
|
+
import {Recipe} from "../recipe";
|
|
19
|
+
import {isSourceFile} from "../tree";
|
|
20
|
+
import {heavyweightType} from "../heavyweight";
|
|
21
|
+
import {Type} from "../java";
|
|
22
|
+
import {typeName} from "./probes";
|
|
23
|
+
|
|
24
|
+
/** Beyond this many objects in one accumulator the walk stops descending. */
|
|
25
|
+
const MAX_OBJECTS = 100_000;
|
|
26
|
+
|
|
27
|
+
const MAX_PATHS = 5;
|
|
28
|
+
|
|
29
|
+
interface Finding {
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly what: string
|
|
32
|
+
readonly phase: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Node {
|
|
36
|
+
readonly value: object
|
|
37
|
+
readonly parent?: Node
|
|
38
|
+
readonly step: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Finds the heavyweight objects that ScanningRecipe accumulators keep, which are trees and the types
|
|
43
|
+
* registerHeavyweight registers. An accumulator lives from the first source file scanned until the cycle ends. The
|
|
44
|
+
* walk reads data properties and built-in collections, and runs no code of the recipe's own.
|
|
45
|
+
*/
|
|
46
|
+
export class AccumulatorContents {
|
|
47
|
+
/** What each recipe's accumulator keeps, by recipe name and then by the shape of the path to it. */
|
|
48
|
+
private readonly findings = new Map<string, Map<string, Finding>>();
|
|
49
|
+
|
|
50
|
+
readonly listener: AccumulatorListener = (accumulators, phase) => {
|
|
51
|
+
const walked = new Set<unknown>();
|
|
52
|
+
for (const [recipe, acc] of accumulators) {
|
|
53
|
+
if (acc !== null && typeof acc === "object" && !walked.has(acc)) {
|
|
54
|
+
walked.add(acc);
|
|
55
|
+
try {
|
|
56
|
+
this.walk(recipe.name, acc, phase);
|
|
57
|
+
} catch {
|
|
58
|
+
// an accumulator the walk can't read is left alone
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
assertNoneFound(): void {
|
|
65
|
+
if (this.findings.size === 0) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const lines = ["A ScanningRecipe's accumulator should keep the values the recipe needs, such as package " +
|
|
69
|
+
"names, versions, paths and ids, and no heavyweight objects, such as trees, cursors, visitors, execution " +
|
|
70
|
+
"contexts and dependency models:"];
|
|
71
|
+
for (const [recipe, found] of this.findings) {
|
|
72
|
+
lines.push(` ${recipe} keeps`);
|
|
73
|
+
const shown = [...found.values()].slice(0, MAX_PATHS);
|
|
74
|
+
lines.push(...shown.map(f => ` ${f.path} -> ${f.what}, ${f.phase}`));
|
|
75
|
+
if (found.size > MAX_PATHS) {
|
|
76
|
+
lines.push(` and ${found.size - MAX_PATHS} more`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
lines.push("An accumulator stays in memory from the first source file scanned until the cycle ends, so " +
|
|
80
|
+
"what it keeps for each source file adds up over a large repository. A NodeResolutionResult can reach " +
|
|
81
|
+
"the model of a whole dependency tree, while small markers such as NodeRuntime are fine to keep. A " +
|
|
82
|
+
"recipe that must keep a heavyweight object opts out with spec.checkLightweightAccumulators = false.");
|
|
83
|
+
throw new Error(lines.join("\n"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Breadth first, so that the path to what the accumulator keeps is a short one. */
|
|
87
|
+
private walk(recipe: string, acc: object, phase: string): void {
|
|
88
|
+
const visited = new Set<object>();
|
|
89
|
+
const queue: Node[] = [];
|
|
90
|
+
let budget = MAX_OBJECTS;
|
|
91
|
+
const visit = (value: unknown, parent: Node | undefined, step: string) => {
|
|
92
|
+
if (!walks(value)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const what = heavyweight(value);
|
|
96
|
+
if (what) {
|
|
97
|
+
const path = pathOf({value, parent, step});
|
|
98
|
+
let found = this.findings.get(recipe);
|
|
99
|
+
if (!found) {
|
|
100
|
+
this.findings.set(recipe, found = new Map());
|
|
101
|
+
}
|
|
102
|
+
const shape = `${path.replace(/\[[^\]]*]/g, "[]")} -> ${what}`;
|
|
103
|
+
if (!found.has(shape)) {
|
|
104
|
+
found.set(shape, {path, what, phase});
|
|
105
|
+
}
|
|
106
|
+
} else if (budget > 0 && !visited.has(value)) {
|
|
107
|
+
budget--;
|
|
108
|
+
visited.add(value);
|
|
109
|
+
queue.push({value, parent, step});
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
visit(acc, undefined, "acc");
|
|
114
|
+
for (let i = 0; i < queue.length; i++) {
|
|
115
|
+
const node = queue[i];
|
|
116
|
+
const value = node.value;
|
|
117
|
+
if (types.isMap(value)) {
|
|
118
|
+
Map.prototype.forEach.call(value, (v: unknown, k: unknown) => {
|
|
119
|
+
if (walks(k) || walks(v)) {
|
|
120
|
+
visit(k, node, `[${key(k)}]`);
|
|
121
|
+
visit(v, node, `[${key(k)}]`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
} else if (types.isSet(value)) {
|
|
125
|
+
let index = 0;
|
|
126
|
+
Set.prototype.forEach.call(value, (v: unknown) => visit(v, node, `[${index++}]`));
|
|
127
|
+
} else {
|
|
128
|
+
// data properties only, since a getter is code of the recipe's own
|
|
129
|
+
const array = Array.isArray(value);
|
|
130
|
+
for (const name of Reflect.ownKeys(value)) {
|
|
131
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, name);
|
|
132
|
+
if (descriptor && "value" in descriptor && !(array && name === "length")) {
|
|
133
|
+
visit(descriptor.value, node, step(name, array));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether the walk looks at a value at all, which it doesn't for primitives and for what refers to no tree. */
|
|
142
|
+
function walks(value: unknown): value is object {
|
|
143
|
+
return value !== null && typeof value === "object" && !types.isProxy(value) && !types.isPromise(value) &&
|
|
144
|
+
!types.isWeakMap(value) && !types.isWeakSet(value) && !types.isAnyArrayBuffer(value) &&
|
|
145
|
+
!types.isArrayBufferView(value) && !types.isDate(value) && !types.isRegExp(value) &&
|
|
146
|
+
!types.isBoxedPrimitive(value) &&
|
|
147
|
+
// a recipe lives for the whole run anyway, and type attribution refers to no tree
|
|
148
|
+
!(value instanceof Recipe) && !Type.isType(value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** How to name what the accumulator keeps, when it is a heavyweight object. */
|
|
152
|
+
function heavyweight(value: object): string | undefined {
|
|
153
|
+
const type = heavyweightType(value);
|
|
154
|
+
if (type === undefined) {
|
|
155
|
+
return undefined;
|
|
156
|
+
} else if (typeof type === "function") {
|
|
157
|
+
return type.name || "an object of an anonymous class";
|
|
158
|
+
}
|
|
159
|
+
return isSourceFile(value) ? `${typeName(type)} ${value.sourcePath}` : typeName(type);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function pathOf(node: Node): string {
|
|
163
|
+
const steps: string[] = [];
|
|
164
|
+
for (let n: Node | undefined = node; n; n = n.parent) {
|
|
165
|
+
steps.push(n.step);
|
|
166
|
+
}
|
|
167
|
+
return steps.reverse().join("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function step(name: string | symbol, array: boolean): string {
|
|
171
|
+
if (typeof name === "symbol") {
|
|
172
|
+
return `[${name.toString()}]`;
|
|
173
|
+
}
|
|
174
|
+
return array && /^\d+$/.test(name) ? `[${name}]` :
|
|
175
|
+
/^[A-Za-z_$][\w$]*$/.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A map key as the path shows it, without calling code of the recipe's own. */
|
|
179
|
+
function key(k: unknown): string {
|
|
180
|
+
let shown: string;
|
|
181
|
+
if (k === null || typeof k !== "object") {
|
|
182
|
+
shown = typeof k === "symbol" ? k.toString() : String(k);
|
|
183
|
+
} else if (isSourceFile(k)) {
|
|
184
|
+
shown = k.sourcePath;
|
|
185
|
+
} else if (k instanceof Recipe) {
|
|
186
|
+
shown = k.name;
|
|
187
|
+
} else if (typeof (k as { kind?: unknown }).kind === "string") {
|
|
188
|
+
shown = typeName((k as { kind: string }).kind);
|
|
189
|
+
} else {
|
|
190
|
+
shown = Object.getPrototypeOf(k)?.constructor?.name || "Object";
|
|
191
|
+
}
|
|
192
|
+
return shown.length > 60 ? `${shown.substring(0, 60)}...` : shown;
|
|
193
|
+
}
|
|
194
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 the original author or authors.
|
|
3
|
+
* <p>
|
|
4
|
+
* Licensed under the Moderne Source Available License (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
* <p>
|
|
8
|
+
* https://docs.moderne.io/licensing/moderne-source-available-license
|
|
9
|
+
* <p>
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import type {SourceSpec} from "./rewrite-test";
|
|
17
|
+
import {Recipe} from "../recipe";
|
|
18
|
+
import {ExecutionContext} from "../execution";
|
|
19
|
+
import {SourceFile} from "../tree";
|
|
20
|
+
import {Result} from "../run";
|
|
21
|
+
import {TreePrinters} from "../print";
|
|
22
|
+
import {describeThrown, probeOnce, thrownBy, typeName, withMarkersOf} from "./probes";
|
|
23
|
+
|
|
24
|
+
/** Runs the recipe on an empty file of each kind it changed, and fails the test if the recipe throws there. */
|
|
25
|
+
export class EmptySources {
|
|
26
|
+
private readonly changed: Set<SourceFile>;
|
|
27
|
+
|
|
28
|
+
constructor(private readonly recipe: Recipe, results: Result[]) {
|
|
29
|
+
this.changed = new Set(results.filter(r => r.before && r.after !== r.before).map(r => r.before!));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async assertNoneThrow(parsed: [SourceSpec<any>, SourceFile][], parseCtx: ExecutionContext,
|
|
33
|
+
recipeCtx: ExecutionContext, cycles: number): Promise<void> {
|
|
34
|
+
const kinds = new Set<string>();
|
|
35
|
+
for (const [spec, before] of parsed) {
|
|
36
|
+
const probe = `empty ${before.kind} ${spec.ext}`;
|
|
37
|
+
if (!this.changed.has(before) || kinds.has(probe)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
kinds.add(probe);
|
|
41
|
+
await probeOnce(this.recipe, probe, async () => {
|
|
42
|
+
const empty = await parseEmpty(before, spec, parseCtx);
|
|
43
|
+
const thrown = empty ? await thrownBy(this.recipe, [empty], recipeCtx, cycles) : undefined;
|
|
44
|
+
if (empty && thrown) {
|
|
45
|
+
throw new Error(this.message(empty, thrown.error));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private message(empty: SourceFile, thrown: unknown): string {
|
|
52
|
+
return [
|
|
53
|
+
"Check for the structure the recipe edits before using it, so that it handles an empty file or leaves " +
|
|
54
|
+
"it unchanged instead of throwing.",
|
|
55
|
+
`${this.recipe.name} threw when run on an empty ${typeName(empty.kind)} at ${empty.sourcePath}` +
|
|
56
|
+
`${withMarkersOf(empty.markers.markers)}:`,
|
|
57
|
+
describeThrown(thrown, " "),
|
|
58
|
+
"The test gives the recipe only files that contain what it edits, but repositories also hold empty " +
|
|
59
|
+
"files and files without that block, such as an empty index.ts, a placeholder tsconfig.json or an " +
|
|
60
|
+
"empty .eslintrc.yml.",
|
|
61
|
+
" - return the tree unchanged when what the visitor assumes is absent: check the kind before a cast " +
|
|
62
|
+
"(`document.value.kind === Json.Kind.Object`), the length before an index (`cu.statements.length > 0`) " +
|
|
63
|
+
"and undefined before a dereference (`documents.documents[0]?.block`)",
|
|
64
|
+
" - if the recipe adds what is missing, build that content correctly for an empty file too",
|
|
65
|
+
" - don't catch the exception to make this pass",
|
|
66
|
+
"Then add a test with an empty source, such as typescript(\"\"), json(\"\") or yaml(\"\"), that states the " +
|
|
67
|
+
"intended result."
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An empty file at the source's path, with the markers that describe the source's project, or undefined for a kind of
|
|
74
|
+
* file that can't be empty.
|
|
75
|
+
*/
|
|
76
|
+
async function parseEmpty(before: SourceFile, spec: SourceSpec<any>,
|
|
77
|
+
parseCtx: ExecutionContext): Promise<SourceFile | undefined> {
|
|
78
|
+
// a file its parser resolves a project from, such as a package.json, can't be empty
|
|
79
|
+
if (before.markers.markers.some(m => m.kind.endsWith("ResolutionResult"))) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
let empty: SourceFile;
|
|
83
|
+
try {
|
|
84
|
+
const parsed: SourceFile[] = [];
|
|
85
|
+
for await (const sourceFile of spec.parser(parseCtx).parse({text: "", sourcePath: before.sourcePath})) {
|
|
86
|
+
parsed.push(sourceFile);
|
|
87
|
+
}
|
|
88
|
+
// some kinds of file can't be empty
|
|
89
|
+
if (parsed.length !== 1 || parsed[0].kind !== before.kind || await TreePrinters.print(parsed[0]) !== "") {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
empty = parsed[0];
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const present = new Set(empty.markers.markers.map(m => m.kind));
|
|
97
|
+
const added = before.markers.markers.filter(m => !present.has(m.kind));
|
|
98
|
+
return {...empty, markers: {...empty.markers, markers: [...empty.markers.markers, ...added]}};
|
|
99
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 the original author or authors.
|
|
3
|
+
* <p>
|
|
4
|
+
* Licensed under the Moderne Source Available License (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
* <p>
|
|
8
|
+
* https://docs.moderne.io/licensing/moderne-source-available-license
|
|
9
|
+
* <p>
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import {Recipe} from "../recipe";
|
|
18
|
+
import {Cursor, isSourceFile, SourceFile, Tree} from "../tree";
|
|
19
|
+
import {Result} from "../run";
|
|
20
|
+
import {TreePrinters} from "../print";
|
|
21
|
+
import {UUID} from "../uuid";
|
|
22
|
+
import {J} from "../java";
|
|
23
|
+
import {JavaScriptVisitor} from "../javascript";
|
|
24
|
+
import type {FormattingListener} from "../javascript/format";
|
|
25
|
+
|
|
26
|
+
interface Request {
|
|
27
|
+
readonly tree: Tree
|
|
28
|
+
readonly sourceFile: UUID
|
|
29
|
+
readonly callSite: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Finds recipes that auto-format a whole source file or class that existed before the run. Test inputs are usually
|
|
34
|
+
* formatted already, so the reformatting this causes on real code rarely shows in an expected output.
|
|
35
|
+
*/
|
|
36
|
+
export class FormattingScope implements FormattingListener {
|
|
37
|
+
private readonly requests: Request[] = [];
|
|
38
|
+
private readonly requested = new Set<Tree>();
|
|
39
|
+
|
|
40
|
+
constructor(private readonly recipe: Recipe) {
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onFormat(tree: Tree, stopAfter: Tree | undefined, cursor: Cursor | undefined): void {
|
|
44
|
+
if (stopAfter || !isWhole(tree) || this.requested.has(tree)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.requested.add(tree);
|
|
48
|
+
const sourceFile = isSourceFile(tree) ? tree : cursor?.firstEnclosing(isSourceFile);
|
|
49
|
+
const site = sourceFile && callSite(new Error().stack ?? "");
|
|
50
|
+
if (sourceFile && site) {
|
|
51
|
+
this.requests.push({tree, sourceFile: sourceFile.id, callSite: site});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async assertFormattedOnlyWhatChanged(results: Result[]): Promise<void> {
|
|
56
|
+
if (this.requests.length === 0 || await isFormatter(this.recipe)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const treesByFile = new Map<UUID, Map<UUID, Tree>>();
|
|
60
|
+
const found = new Map<string, Set<string>>();
|
|
61
|
+
for (const request of this.requests) {
|
|
62
|
+
const result = results.find(r => r.before?.id === request.sourceFile && r.after && r.after !== r.before);
|
|
63
|
+
if (!result || await onlyWhitespaceChanged(result)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const before = result.before!;
|
|
67
|
+
let trees = treesByFile.get(before.id);
|
|
68
|
+
if (!trees) {
|
|
69
|
+
treesByFile.set(before.id, trees = await treesById(before));
|
|
70
|
+
}
|
|
71
|
+
const original = trees.get(request.tree.id);
|
|
72
|
+
if (original && !await holdsNoneOfItsChildren(original, request.tree)) {
|
|
73
|
+
let targets = found.get(request.callSite);
|
|
74
|
+
if (!targets) {
|
|
75
|
+
found.set(request.callSite, targets = new Set());
|
|
76
|
+
}
|
|
77
|
+
targets.add(`${describe(request.tree)} of ${before.sourcePath}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (found.size > 0) {
|
|
81
|
+
throw new Error(this.message(found));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private message(found: Map<string, Set<string>>): string {
|
|
86
|
+
return [
|
|
87
|
+
"Format only what the recipe changed, not the whole class or file around it. These calls of " +
|
|
88
|
+
`${this.recipe.name} auto-formatted a whole file or class that existed before the run, with no stopAfter:`,
|
|
89
|
+
...[...found].flatMap(([site, targets]) => [` ${site}`, ...[...targets].map(target => ` ${target}`)]),
|
|
90
|
+
"That rewrites the whitespace of code the recipe did not change. Test inputs are usually formatted " +
|
|
91
|
+
"already, so expected outputs rarely show it, but on real code the diff will carry unrelated " +
|
|
92
|
+
"reformatting. Instead:",
|
|
93
|
+
" - format the element you created or replaced: maybeAutoFormat(original, changed, ctx) on that " +
|
|
94
|
+
"element, or autoFormat(newElement, ctx, undefined, cursor) with a cursor on its parent",
|
|
95
|
+
" - when a new element only formats right inside its container, format a copy of the container that " +
|
|
96
|
+
"holds just the new element, and add the formatted element to the original",
|
|
97
|
+
" - after changing a declaration's decorators or modifiers, stop after its header: " +
|
|
98
|
+
"maybeAutoFormat(c, updated, ctx, updated.name, this.cursor.parent)",
|
|
99
|
+
" - when you remove an element, give its prefix to the element that takes its place",
|
|
100
|
+
" - re-indent statements you moved with autoIndent(..) rather than autoFormat",
|
|
101
|
+
" - a template already formats what it inserts, so drop the extra call",
|
|
102
|
+
"Recipes named with a `format` segment, such as org.openrewrite.javascript.format.auto-format, are exempt. " +
|
|
103
|
+
"Any other recipe whose purpose is formatting opts out with spec.checkMinimalFormatting = false."
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A source file or a class. */
|
|
109
|
+
function isWhole(tree: Tree): boolean {
|
|
110
|
+
return isSourceFile(tree) || tree.kind === J.Kind.ClassDeclaration;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface Frame {
|
|
114
|
+
readonly fn: string
|
|
115
|
+
readonly location: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The code that asked for the format, from the stack of the request, or undefined when that is a formatter, a
|
|
120
|
+
* template, or the scheduler running a recipe whose own visitor is a formatter.
|
|
121
|
+
*/
|
|
122
|
+
function callSite(stack: string): string | undefined {
|
|
123
|
+
const frames: Frame[] = stack.split("\n").map(line => line.trim()).filter(line => line.startsWith("at "))
|
|
124
|
+
.map(line => {
|
|
125
|
+
const m = /^at (?:async )?(?:(.*?) \((.*)\)|(.*))$/.exec(line)!;
|
|
126
|
+
return {fn: m[1] ?? "", location: (m[2] ?? m[3]).split(process.cwd() + path.sep).join("")};
|
|
127
|
+
});
|
|
128
|
+
// the listener, the formatter and the helpers that run it
|
|
129
|
+
let i = frames.findIndex(f => /\.visit$/.test(f.fn) && inFormat(f));
|
|
130
|
+
if (i < 0) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
let helper: string | undefined;
|
|
134
|
+
for (i++; i < frames.length && inFormat(frames[i]) && /^(maybeAutoFormat|autoFormat)$/.test(frames[i].fn); i++) {
|
|
135
|
+
helper = frames[i].fn;
|
|
136
|
+
}
|
|
137
|
+
// a precondition and a scanning recipe's editor hand the tree on to the visitor they wrap, after an await
|
|
138
|
+
while (i < frames.length && (isWrapper(frames[i]) || frames[i].location.startsWith("node:"))) {
|
|
139
|
+
i++;
|
|
140
|
+
}
|
|
141
|
+
const caller = frames[i];
|
|
142
|
+
if (!caller) {
|
|
143
|
+
return undefined;
|
|
144
|
+
} else if (/(^|[\\/])(src|dist)[\\/]visitor\.[jt]s:/.test(caller.location)) {
|
|
145
|
+
// the visitor that asked is gone from the stack by the time its after-visitors run
|
|
146
|
+
return "A visitor's afterVisit.push(new AutoformatVisitor()) ran on";
|
|
147
|
+
} else if (inFormat(caller) || /[\\/]templating[\\/]/.test(caller.location) ||
|
|
148
|
+
/(^|[\\/])(src|dist)[\\/]run\.[jt]s:/.test(caller.location)) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
return `${caller.fn || "<anonymous>"} (${caller.location}) ${helper ? `called ${helper} on` : "ran an AutoformatVisitor on"}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function inFormat(frame: Frame): boolean {
|
|
155
|
+
return /[\\/]format[\\/]/.test(frame.location);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The visit of a precondition or of a scanning recipe's editor, which runs the visitor it wraps. */
|
|
159
|
+
function isWrapper(frame: Frame): boolean {
|
|
160
|
+
return /\.visit$/.test(frame.fn) && /(^|[\\/])(src|dist)[\\/](preconditions|recipe)\.[jt]s:/.test(frame.location);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A formatting recipe may format whole files, and so may the recipes run alongside it. */
|
|
164
|
+
async function isFormatter(recipe: Recipe): Promise<boolean> {
|
|
165
|
+
if (/(^|\.)format\./.test(recipe.name)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
for (const child of await recipe.recipeList()) {
|
|
169
|
+
if (await isFormatter(child)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function onlyWhitespaceChanged(result: Result): Promise<boolean> {
|
|
177
|
+
const [before, after] = [await TreePrinters.print(result.before!), await TreePrinters.print(result.after!)];
|
|
178
|
+
return before.replace(/\s+/g, "") === after.replace(/\s+/g, "");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function treesById(sourceFile: SourceFile): Promise<Map<UUID, Tree>> {
|
|
182
|
+
const trees = new Map<UUID, Tree>();
|
|
183
|
+
await new class extends JavaScriptVisitor<undefined> {
|
|
184
|
+
protected override async preVisit(tree: J): Promise<J | undefined> {
|
|
185
|
+
trees.set(tree.id, tree);
|
|
186
|
+
return tree;
|
|
187
|
+
}
|
|
188
|
+
}().visit(sourceFile, undefined);
|
|
189
|
+
return trees;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A tree formatted with none of the children it had reformats no existing code below it: a class with its body
|
|
194
|
+
* emptied to be put back afterwards, or a copy of a container holding only a new element. A tree that had no children
|
|
195
|
+
* to begin with would format them on real code.
|
|
196
|
+
*/
|
|
197
|
+
async function holdsNoneOfItsChildren(original: Tree, formatted: Tree): Promise<boolean> {
|
|
198
|
+
const children = await childrenOf(original);
|
|
199
|
+
if (children.size === 0) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
for (const child of await childrenOf(formatted)) {
|
|
203
|
+
if (children.has(child)) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The members of a class's body, or the trees directly below any other tree. */
|
|
211
|
+
async function childrenOf(tree: Tree): Promise<Set<UUID>> {
|
|
212
|
+
if (tree.kind === J.Kind.ClassDeclaration) {
|
|
213
|
+
return new Set((tree as J.ClassDeclaration).body.statements.map(s => s.element.id));
|
|
214
|
+
}
|
|
215
|
+
const children = new Set<UUID>();
|
|
216
|
+
await new class extends JavaScriptVisitor<undefined> {
|
|
217
|
+
protected override async preVisit(t: J): Promise<J | undefined> {
|
|
218
|
+
if (t !== tree) {
|
|
219
|
+
children.add(t.id);
|
|
220
|
+
this.stopAfterPreVisit();
|
|
221
|
+
}
|
|
222
|
+
return t;
|
|
223
|
+
}
|
|
224
|
+
}().visit(tree, undefined);
|
|
225
|
+
return children;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Reads as "all" or "class `A`". */
|
|
229
|
+
function describe(tree: Tree): string {
|
|
230
|
+
return isSourceFile(tree) ? "all" : `class \`${(tree as J.ClassDeclaration).name.simpleName}\``;
|
|
231
|
+
}
|