@openrewrite/rewrite 8.96.0 → 8.96.2

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.
@@ -255,6 +255,9 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
255
255
 
256
256
  public async visitContainer<T extends J>(container: J.Container<T>, p: P): Promise<J.Container<T>> {
257
257
  const ret = await super.visitContainer(container, p) as J.Container<T>;
258
+ if (this.cursor.value?.kind === J.Kind.TypeParameter) {
259
+ return ret;
260
+ }
258
261
  return produce(ret, draft => {
259
262
  if (draft.elements.length > 0) {
260
263
  // Apply beforeComma rule to all elements except the last
@@ -321,7 +324,7 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
321
324
  const ret = await super.visitForLoop(forLoop, p) as J.ForLoop;
322
325
  return produceAsync(ret, async draft => {
323
326
  draft.control.prefix.whitespace = this.style.beforeParentheses.forParentheses ? " " : "";
324
- draft.control.init = await Promise.all(draft.control.init.map(async (oneInit, index) => {
327
+ draft.control.init.forEach((oneInit, index) => {
325
328
  if (oneInit.element.kind === J.Kind.VariableDeclarations) {
326
329
  const vd = oneInit.element as Draft<J.VariableDeclarations>;
327
330
  if (vd.modifiers && vd.modifiers.length > 0) {
@@ -330,8 +333,7 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
330
333
  }
331
334
  oneInit.after.whitespace = "";
332
335
  this.spaceBeforeRightPaddedElementDraft(oneInit, index === 0 ? this.style.within.forParentheses : true);
333
- return oneInit;
334
- }));
336
+ });
335
337
  if (draft.control.condition) {
336
338
  draft.control.condition.element.prefix.whitespace = " ";
337
339
  draft.control.condition.after.whitespace = this.style.other.beforeForSemicolon ? " " : "";
@@ -641,9 +643,14 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
641
643
  const hasConstraint = constraintType.element.kind !== J.Kind.Empty;
642
644
  const hasDefault = defaultType.element.kind !== J.Kind.Empty;
643
645
 
646
+ if (hasDefault && !defaultType.element.prefix.whitespace.includes("\n")) {
647
+ defaultType.element.prefix.whitespace = this.style.aroundOperators.assignment ? " " : "";
648
+ }
644
649
  if (hasConstraint) {
645
- // Space before '=' for default type (in the `after` of constraint type)
646
- if (hasDefault && !constraintType.after.whitespace.includes("\n")) {
650
+ if (!hasDefault) {
651
+ this.spaceAfterRightPaddedDraft(constraintType, this.style.other.beforeComma);
652
+ } else if (!constraintType.after.whitespace.includes("\n")) {
653
+ // Space before '=' for default type (in the `after` of constraint type)
647
654
  constraintType.after.whitespace = this.style.aroundOperators.assignment ? " " : "";
648
655
  }
649
656
  } else if (hasDefault) {
@@ -172,43 +172,39 @@ export class OrderImports extends Recipe {
172
172
  return; // Nothing to sort
173
173
  }
174
174
 
175
- // Handle trailing comma
176
- const trailingComma = elements.length > 0 &&
177
- elements[elements.length - 1].markers?.markers.find(m => m.kind === J.Markers.TrailingComma);
178
- if (trailingComma) {
179
- elements[elements.length - 1].markers.markers =
180
- elements[elements.length - 1].markers.markers.filter(m => m.kind !== J.Markers.TrailingComma);
181
- }
175
+ const bySortKey = (a: { element: unknown }, b: { element: unknown }) =>
176
+ this.getSpecifierSortKey(a.element as JS.ImportSpecifier)
177
+ .localeCompare(this.getSpecifierSortKey(b.element as JS.ImportSpecifier));
178
+ if (elements.some((e, i) => i > 0 && bySortKey(elements[i - 1], e) > 0)) {
179
+ const trailingComma = elements[elements.length - 1].markers?.markers.find(m => m.kind === J.Markers.TrailingComma);
180
+ if (trailingComma) {
181
+ elements[elements.length - 1].markers.markers =
182
+ elements[elements.length - 1].markers.markers.filter(m => m.kind !== J.Markers.TrailingComma);
183
+ }
184
+
185
+ elements.sort(bySortKey);
182
186
 
183
- // Sort by the imported name (not alias)
184
- elements.sort((a, b) => {
185
- const nameA = this.getSpecifierSortKey(a.element as JS.ImportSpecifier);
186
- const nameB = this.getSpecifierSortKey(b.element as JS.ImportSpecifier);
187
- return nameA.localeCompare(nameB);
188
- });
187
+ if (trailingComma) {
188
+ elements[elements.length - 1].markers.markers.push(trailingComma);
189
+ }
190
+ }
189
191
 
190
- // Normalize spacing based on es6ImportExportBraces style
191
192
  const braceSpace = useBraceSpaces ? " " : "";
193
+ const differs = (space: J.Space, whitespace: string) =>
194
+ space.whitespace !== whitespace || space.comments.length > 0;
195
+ const spaceOf = (whitespace: string): J.Space => ({kind: J.Kind.Space, whitespace, comments: []});
192
196
  for (let i = 0; i < elements.length; i++) {
193
- if (i === 0) {
194
- // First element: space after opening brace based on style
195
- elements[i].element.prefix = {kind: J.Kind.Space, whitespace: braceSpace, comments: []};
196
- } else {
197
- // Other elements: space after comma
198
- elements[i].element.prefix = {kind: J.Kind.Space, whitespace: ' ', comments: []};
197
+ const whitespace = i === 0 ? braceSpace : ' ';
198
+ if (differs(elements[i].element.prefix, whitespace)) {
199
+ elements[i].element.prefix = spaceOf(whitespace);
199
200
  }
200
201
  }
201
- // Last element: space before closing brace based on style
202
- elements[elements.length - 1].after = {kind: J.Kind.Space, whitespace: braceSpace, comments: []};
203
-
204
- // Restore trailing comma to last element
205
- if (trailingComma && elements.length > 0 &&
206
- !elements[elements.length - 1].markers.markers.find(m => m.kind === J.Markers.TrailingComma)) {
207
- elements[elements.length - 1].markers.markers.push(trailingComma);
202
+ if (differs(elements[elements.length - 1].after, braceSpace)) {
203
+ elements[elements.length - 1].after = spaceOf(braceSpace);
208
204
  }
209
205
  });
210
206
 
211
- ret.push(produce(importPadded, draft => {
207
+ ret.push(importSorted === import_ ? importPadded : produce(importPadded, draft => {
212
208
  draft.element = importSorted;
213
209
  }));
214
210
  } else {
@@ -163,6 +163,9 @@ export class FindDependency extends Recipe {
163
163
  private async markDependency(member: Json.Member, ctx: ExecutionContext): Promise<Json.Member> {
164
164
  const visitedMember = await super.visitMember(member, ctx) as Json.Member;
165
165
  const markedKey = foundSearchResult(visitedMember.key.element);
166
+ if (markedKey === visitedMember.key.element) {
167
+ return visitedMember;
168
+ }
166
169
  return {
167
170
  ...visitedMember,
168
171
  key: {
package/src/markers.ts CHANGED
@@ -275,6 +275,10 @@ export function markupDebug<T extends { markers: Markers }>(t: T, message: strin
275
275
  * Helper to add a markup marker to a tree node.
276
276
  */
277
277
  function addMarkup<T extends { markers: Markers }>(t: T, markup: Markup): T {
278
+ if (t.markers.markers.some(m => m.kind === markup.kind &&
279
+ (m as Markup).message === markup.message && (m as Markup).detail === markup.detail)) {
280
+ return t;
281
+ }
278
282
  return {
279
283
  ...t,
280
284
  markers: {
@@ -27,6 +27,7 @@ import {isNamedStyles} from "../style";
27
27
  import {JavaScriptVisitor} from "../javascript";
28
28
  import {attributionHint, dumpTypesIfRequested} from "../javascript/type-report";
29
29
  import {J, Type} from "../java";
30
+ import {DATA_TABLE_STORE, InMemoryDataTableStore} from "../data-table";
30
31
 
31
32
  export interface SourceSpec<T extends SourceFile> {
32
33
  kind: string,
@@ -44,9 +45,11 @@ interface RecipeSpecConfiguration {
44
45
  checkParsePrintIdempotence: boolean;
45
46
  allowEmptyDiff: boolean;
46
47
  checkReportingMarkersOnly: boolean;
48
+ cycles?: number;
49
+ expectedCyclesThatMakeChanges?: number;
47
50
  recipe: Recipe;
48
51
  recipeExecutionContext: ExecutionContext;
49
- dataTableAssertions: { [key: string]: (rows: any[]) => void };
52
+ dataTableAssertions: { [key: string]: (rows: any[]) => void | Promise<void> };
50
53
  }
51
54
 
52
55
  export class RecipeSpec {
@@ -59,6 +62,19 @@ export class RecipeSpec {
59
62
  */
60
63
  checkReportingMarkersOnly: boolean = true
61
64
 
65
+ /**
66
+ * The cycles to run, by default one more than {@link expectedCyclesThatMakeChanges} and at least two. The
67
+ * cycles after the expected ones, and a run on a fresh parse of the output, fail the test if they change
68
+ * anything, so setting this to 1 turns the stability checks off.
69
+ */
70
+ cycles?: number
71
+
72
+ /**
73
+ * How many cycles a recipe needs to settle, by default 1. A recipe that only reaches its result over
74
+ * several cycles declares how many here, and the test then compares the output of the last of them.
75
+ */
76
+ expectedCyclesThatMakeChanges?: number
77
+
62
78
  recipe: Recipe = new NoopRecipe()
63
79
 
64
80
  /**
@@ -72,7 +88,7 @@ export class RecipeSpec {
72
88
  */
73
89
  recipeExecutionContext: ExecutionContext = this.executionContext;
74
90
 
75
- private dataTableAssertions: { [key: string]: (rows: any[]) => void } = {}
91
+ private dataTableAssertions: { [key: string]: (rows: any[]) => void | Promise<void> } = {}
76
92
 
77
93
  // A suite typically declares one spec and configures it per test, which makes the spec a
78
94
  // channel between tests unless each test starts from the same configuration. A consumer opts
@@ -85,7 +101,11 @@ export class RecipeSpec {
85
101
  RecipeSpec.live?.push(this);
86
102
  }
87
103
 
88
- dataTable<Row>(name: string, allRows: (rows: Row[]) => void) {
104
+ /**
105
+ * Asserts on the rows the first cycle of each subsequent {@link rewriteRun} writes to the named data
106
+ * table, failing the run when the recipe wrote none.
107
+ */
108
+ dataTable<Row>(name: string, allRows: (rows: Row[]) => void | Promise<void>) {
89
109
  this.dataTableAssertions[name] = allRows;
90
110
  }
91
111
 
@@ -94,6 +114,8 @@ export class RecipeSpec {
94
114
  checkParsePrintIdempotence: this.checkParsePrintIdempotence,
95
115
  allowEmptyDiff: this.allowEmptyDiff,
96
116
  checkReportingMarkersOnly: this.checkReportingMarkersOnly,
117
+ cycles: this.cycles,
118
+ expectedCyclesThatMakeChanges: this.expectedCyclesThatMakeChanges,
97
119
  recipe: this.recipe,
98
120
  recipeExecutionContext: this.recipeExecutionContext,
99
121
  dataTableAssertions: {...this.dataTableAssertions}
@@ -194,17 +216,151 @@ export class RecipeSpec {
194
216
  }
195
217
  }
196
218
 
197
- const changeset = (await scheduleRun(this.recipe,
198
- allParsed.map(([_, sourceFile]) => sourceFile),
199
- this.recipeExecutionContext)).changeset;
219
+ this.recipeExecutionContext.messages[DATA_TABLE_STORE] = new InMemoryDataTableStore();
220
+ const expectedCycles = this.expectedCyclesThatMakeChanges ?? 1;
221
+ const cycles = this.cycles ?? Math.max(2, expectedCycles + 1);
222
+ const {changeset, unsettled} = await this.runCycles(allParsed.map(([_, sourceFile]) => sourceFile),
223
+ cycles, expectedCycles);
200
224
 
201
225
  this.checkReportingMarkersOnly && await this.expectNoAddedMarkers(flattenedSpecs, allParsed, changeset);
202
226
  await this.expectResultsToMatchAfter(flattenedSpecs, changeset, allParsed);
203
227
  await this.expectGeneratedFiles(flattenedSpecs, changeset);
228
+ if (unsettled) {
229
+ await this.expectNoFurtherChanges(unsettled.changes,
230
+ `Expected recipe to complete in ${pluralCycles(expectedCycles)}, but cycle ${unsettled.cycle} changed`);
231
+ }
232
+ cycles > expectedCycles && await this.expectStableOnFreshParse(flattenedSpecs, allParsed, changeset);
233
+ await this.expectDataTables();
234
+ }
235
+
236
+ private async expectDataTables() {
237
+ const store: InMemoryDataTableStore = this.recipeExecutionContext.messages[DATA_TABLE_STORE];
238
+ const dataTables = store.getDataTables();
239
+ for (const [name, assertion] of Object.entries(this.dataTableAssertions)) {
240
+ const dataTable = dataTables.find(dt => dt.descriptor.name === name);
241
+ if (!dataTable) {
242
+ throw new Error(`No data table named ${name}` + (dataTables.length === 0 ? "" :
243
+ `\nFound data tables: ${[...new Set(dataTables.map(dt => dt.descriptor.name))].join(", ")}`));
244
+ }
245
+ await assertion(store.getRows(name, dataTable.group));
246
+ }
247
+ }
248
+
249
+ private async runCycles(sources: SourceFile[], cycles: number, expectedCycles: number):
250
+ Promise<{ changeset: Result[], unsettled?: { cycle: number, changes: Result[] } }> {
251
+ const lineages: { origin?: SourceFile, latest?: SourceFile }[] = sources.map(s => ({origin: s, latest: s}));
252
+ const changesetOf = () => lineages.map(({origin, latest}) => new Result(origin, latest));
253
+ let changeset: Result[] | undefined;
254
+ let cyclesThatMadeChanges = 0;
255
+ for (let cycle = 1; cycle <= cycles; cycle++) {
256
+ const live = lineages.filter(l => l.latest).map(l => l.latest!);
257
+ const ctx = cycle === 1 ? this.recipeExecutionContext : this.laterRunContext();
258
+ const changes = (await scheduleRun(this.recipe, live, ctx)).changeset.filter(r => r.before !== r.after);
259
+ if (changes.length === 0) {
260
+ break;
261
+ }
262
+ if (changeset) {
263
+ return {changeset, unsettled: {cycle, changes}};
264
+ }
265
+ cyclesThatMadeChanges++;
266
+ for (const {before, after} of changes) {
267
+ const lineage = before && lineages.find(l => l.latest === before);
268
+ if (lineage) {
269
+ lineage.latest = after;
270
+ } else {
271
+ lineages.push({latest: after});
272
+ }
273
+ }
274
+ if (cycle >= expectedCycles) {
275
+ changeset = changesetOf();
276
+ }
277
+ }
278
+ if (cyclesThatMadeChanges < expectedCycles && expectedCycles > 1) {
279
+ throw new Error(`Expected recipe to complete in ${pluralCycles(expectedCycles)}, ` +
280
+ `but it settled after ${pluralCycles(cyclesThatMadeChanges)}. Lower expectedCyclesThatMakeChanges.`);
281
+ }
282
+ return {changeset: changeset ?? changesetOf()};
283
+ }
204
284
 
205
- // for (const [name, assertion] of Object.entries(this.dataTableAssertions)) {
206
- // assertion(getRows(name, this.recipeExecutionContext));
207
- // }
285
+ /** Data tables are written by the first cycle only, as in Java. */
286
+ private laterRunContext(): ExecutionContext {
287
+ const messages = {...this.recipeExecutionContext.messages};
288
+ delete messages[DATA_TABLE_STORE];
289
+ return new ExecutionContext(messages);
290
+ }
291
+
292
+ private async expectNoFurtherChanges(changes: Result[], headline: string, sanitized: boolean = false) {
293
+ const print = (sourceFile: SourceFile) => sanitized ? printSanitized(sourceFile) : TreePrinters.print(sourceFile);
294
+ const found: string[] = [];
295
+ for (const {before, after} of changes) {
296
+ if (!before) {
297
+ found.push(` ${after!.sourcePath}, which it generated`);
298
+ } else if (!after) {
299
+ found.push(` ${before.sourcePath}, which it deleted`);
300
+ } else {
301
+ const [expected, actual] = [await print(before), await print(after)];
302
+ if (expected !== actual) {
303
+ found.push(` ${after.sourcePath}\nExpected:\n${expected}\n\nActual:\n${actual}`);
304
+ } else if (!sanitized && !this.allowEmptyDiff) {
305
+ found.push(` ${after.sourcePath}, whose LST it changed without changing the printed source`);
306
+ }
307
+ }
308
+ }
309
+ if (found.length > 0) {
310
+ throw new Error([
311
+ `${headline} the sources again:`,
312
+ ...found,
313
+ "A recipe should recognize code it has already changed and leave it alone, so that running it " +
314
+ "again changes nothing. A recipe that is meant to need more cycles declares how many with " +
315
+ "expectedCyclesThatMakeChanges."
316
+ ].join("\n"));
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Code that the next run reads is parsed from the printed output, not the LST this run produced, and a
322
+ * recipe that recognizes its own output only by the shape of that LST changes it again.
323
+ */
324
+ private async expectStableOnFreshParse(specs: SourceSpec<any>[], parsed: [SourceSpec<any>, SourceFile][],
325
+ changeset: Result[]) {
326
+ if (!changeset.some(r => r.before !== r.after)) {
327
+ return;
328
+ }
329
+ const finals = changeset.filter(r => r.after).map(({before, after}): [SourceSpec<any> | undefined, SourceFile] => [
330
+ before ? parsed.find(([_, sourceFile]) => sourceFile === before)?.[0] :
331
+ specs.find(s => s.path === after!.sourcePath),
332
+ after!
333
+ ]);
334
+
335
+ const reparsedByFinal = new Map<SourceFile, SourceFile>();
336
+ const kinds = new Set(finals.filter(([spec]) => spec).map(([spec]) => spec!.kind));
337
+ for (const kind of kinds) {
338
+ const group = finals.filter(([spec]) => spec?.kind === kind);
339
+ const inputs = [];
340
+ for (const [_, sourceFile] of group) {
341
+ inputs.push({text: await printSanitized(sourceFile), sourcePath: sourceFile.sourcePath});
342
+ }
343
+ let i = 0;
344
+ try {
345
+ for await (const reparsed of group[0][0]!.parser(this.executionContext).parse(...inputs)) {
346
+ if (!isParseError(reparsed) && await printSanitized(reparsed) === inputs[i].text) {
347
+ // What the source file carries, like its styles, comes from outside its text
348
+ reparsedByFinal.set(group[i][1], {...reparsed, markers: group[i][1].markers});
349
+ }
350
+ i++;
351
+ }
352
+ } catch {
353
+ // files that do not parse again keep the LST the recipe produced
354
+ }
355
+ }
356
+ if (reparsedByFinal.size === 0) {
357
+ return;
358
+ }
359
+
360
+ const sources = finals.map(([_, sourceFile]) => reparsedByFinal.get(sourceFile) ?? sourceFile);
361
+ const changes = (await scheduleRun(this.recipe, sources, this.laterRunContext())).changeset
362
+ .filter(r => r.before !== r.after);
363
+ await this.expectNoFurtherChanges(changes, "Running the recipe on a fresh parse of its output changed", true);
208
364
  }
209
365
 
210
366
  private async expectNoParseFailures(parsed: [SourceSpec<any>, SourceFile][]) {
@@ -291,9 +447,7 @@ export class RecipeSpec {
291
447
  */
292
448
  private async freshParsePlacements(spec: SourceSpec<any>, after: SourceFile): Promise<Set<string>> {
293
449
  try {
294
- // Without the comments that search results and markup print as
295
- const out = new PrintOutputCapture(MarkerPrinter.SANITIZED);
296
- const text = await TreePrinters.printer(after).print(after, out);
450
+ const text = await printSanitized(after);
297
451
  const reparsed = await spec.parser(this.executionContext).parseOne({text, sourcePath: after.sourcePath});
298
452
  return isParseError(reparsed) ? new Set() : new Set(markerPlacements(reparsed).keys());
299
453
  } catch {
@@ -420,6 +574,15 @@ class ValidateWhitespaceVisitor extends JavaScriptVisitor<ExecutionContext> {
420
574
  }
421
575
  }
422
576
 
577
+ /** Without the comments that search results and markup print as. */
578
+ function printSanitized(sourceFile: SourceFile): Promise<string> {
579
+ return TreePrinters.printer(sourceFile).print(sourceFile, new PrintOutputCapture(MarkerPrinter.SANITIZED));
580
+ }
581
+
582
+ function pluralCycles(count: number): string {
583
+ return `${count} cycle${count === 1 ? "" : "s"}`;
584
+ }
585
+
423
586
  /** Markers that report on an LST rather than describe it, which a recipe is free to leave. */
424
587
  const reportingMarkerKinds = new Set<string>([
425
588
  MarkersKind.SearchResult,