@openrewrite/rewrite 8.96.4 → 8.96.5

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.
Files changed (123) hide show
  1. package/dist/execution.d.ts +2 -0
  2. package/dist/execution.d.ts.map +1 -1
  3. package/dist/execution.js +4 -0
  4. package/dist/execution.js.map +1 -1
  5. package/dist/heavyweight.d.ts +8 -0
  6. package/dist/heavyweight.d.ts.map +1 -0
  7. package/dist/heavyweight.js +45 -0
  8. package/dist/heavyweight.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/java/visitor.d.ts.map +1 -1
  14. package/dist/java/visitor.js +16 -2
  15. package/dist/java/visitor.js.map +1 -1
  16. package/dist/javascript/add-import.d.ts +2 -1
  17. package/dist/javascript/add-import.d.ts.map +1 -1
  18. package/dist/javascript/add-import.js +14 -2
  19. package/dist/javascript/add-import.js.map +1 -1
  20. package/dist/javascript/amd.d.ts.map +1 -1
  21. package/dist/javascript/amd.js +2 -1
  22. package/dist/javascript/amd.js.map +1 -1
  23. package/dist/javascript/cleanup/add-parse-int-radix.js +1 -1
  24. package/dist/javascript/cleanup/add-parse-int-radix.js.map +1 -1
  25. package/dist/javascript/format/format.d.ts +10 -0
  26. package/dist/javascript/format/format.d.ts.map +1 -1
  27. package/dist/javascript/format/format.js +10 -2
  28. package/dist/javascript/format/format.js.map +1 -1
  29. package/dist/javascript/format/tabs-and-indents-visitor.d.ts.map +1 -1
  30. package/dist/javascript/format/tabs-and-indents-visitor.js +4 -0
  31. package/dist/javascript/format/tabs-and-indents-visitor.js.map +1 -1
  32. package/dist/javascript/format/whitespace-reconciler.d.ts.map +1 -1
  33. package/dist/javascript/format/whitespace-reconciler.js +4 -0
  34. package/dist/javascript/format/whitespace-reconciler.js.map +1 -1
  35. package/dist/javascript/node-resolution-result.d.ts.map +1 -1
  36. package/dist/javascript/node-resolution-result.js +2 -0
  37. package/dist/javascript/node-resolution-result.js.map +1 -1
  38. package/dist/javascript/parser-utils.d.ts.map +1 -1
  39. package/dist/javascript/parser-utils.js +9 -6
  40. package/dist/javascript/parser-utils.js.map +1 -1
  41. package/dist/javascript/templating/engine.d.ts.map +1 -1
  42. package/dist/javascript/templating/engine.js +11 -6
  43. package/dist/javascript/templating/engine.js.map +1 -1
  44. package/dist/javascript/templating/pattern.d.ts.map +1 -1
  45. package/dist/javascript/templating/pattern.js +8 -0
  46. package/dist/javascript/templating/pattern.js.map +1 -1
  47. package/dist/javascript/templating/placeholder-replacement.d.ts.map +1 -1
  48. package/dist/javascript/templating/placeholder-replacement.js +15 -0
  49. package/dist/javascript/templating/placeholder-replacement.js.map +1 -1
  50. package/dist/javascript/visitor.d.ts.map +1 -1
  51. package/dist/javascript/visitor.js +21 -0
  52. package/dist/javascript/visitor.js.map +1 -1
  53. package/dist/recipe.d.ts +5 -0
  54. package/dist/recipe.d.ts.map +1 -1
  55. package/dist/recipe.js +5 -0
  56. package/dist/recipe.js.map +1 -1
  57. package/dist/rewrite-javascript-version.txt +1 -1
  58. package/dist/run.d.ts +7 -1
  59. package/dist/run.d.ts.map +1 -1
  60. package/dist/run.js +40 -2
  61. package/dist/run.js.map +1 -1
  62. package/dist/test/accumulator-contents.d.ts +15 -0
  63. package/dist/test/accumulator-contents.d.ts.map +1 -0
  64. package/dist/test/accumulator-contents.js +185 -0
  65. package/dist/test/accumulator-contents.js.map +1 -0
  66. package/dist/test/empty-sources.d.ts +14 -0
  67. package/dist/test/empty-sources.d.ts.map +1 -0
  68. package/dist/test/empty-sources.js +98 -0
  69. package/dist/test/empty-sources.js.map +1 -0
  70. package/dist/test/formatting-scope.d.ts +18 -0
  71. package/dist/test/formatting-scope.d.ts.map +1 -0
  72. package/dist/test/formatting-scope.js +244 -0
  73. package/dist/test/formatting-scope.js.map +1 -0
  74. package/dist/test/probes.d.ts +25 -0
  75. package/dist/test/probes.d.ts.map +1 -0
  76. package/dist/test/probes.js +189 -0
  77. package/dist/test/probes.js.map +1 -0
  78. package/dist/test/rewrite-test.d.ts +27 -0
  79. package/dist/test/rewrite-test.d.ts.map +1 -1
  80. package/dist/test/rewrite-test.js +191 -21
  81. package/dist/test/rewrite-test.js.map +1 -1
  82. package/dist/test/tree-shapes.d.ts +15 -0
  83. package/dist/test/tree-shapes.d.ts.map +1 -0
  84. package/dist/test/tree-shapes.js +288 -0
  85. package/dist/test/tree-shapes.js.map +1 -0
  86. package/dist/test/unparsed-sources.d.ts +17 -0
  87. package/dist/test/unparsed-sources.d.ts.map +1 -0
  88. package/dist/test/unparsed-sources.js +113 -0
  89. package/dist/test/unparsed-sources.js.map +1 -0
  90. package/dist/tree.d.ts.map +1 -1
  91. package/dist/tree.js +2 -0
  92. package/dist/tree.js.map +1 -1
  93. package/dist/visitor.d.ts.map +1 -1
  94. package/dist/visitor.js +2 -0
  95. package/dist/visitor.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/execution.ts +6 -0
  98. package/src/heavyweight.ts +42 -0
  99. package/src/index.ts +1 -0
  100. package/src/java/visitor.ts +16 -2
  101. package/src/javascript/add-import.ts +11 -2
  102. package/src/javascript/amd.ts +4 -2
  103. package/src/javascript/cleanup/add-parse-int-radix.ts +2 -2
  104. package/src/javascript/format/format.ts +18 -0
  105. package/src/javascript/format/tabs-and-indents-visitor.ts +4 -0
  106. package/src/javascript/format/whitespace-reconciler.ts +4 -0
  107. package/src/javascript/node-resolution-result.ts +3 -0
  108. package/src/javascript/parser-utils.ts +10 -6
  109. package/src/javascript/templating/engine.ts +11 -6
  110. package/src/javascript/templating/pattern.ts +10 -0
  111. package/src/javascript/templating/placeholder-replacement.ts +16 -0
  112. package/src/javascript/visitor.ts +30 -0
  113. package/src/recipe.ts +5 -0
  114. package/src/run.ts +30 -2
  115. package/src/test/accumulator-contents.ts +194 -0
  116. package/src/test/empty-sources.ts +99 -0
  117. package/src/test/formatting-scope.ts +231 -0
  118. package/src/test/probes.ts +156 -0
  119. package/src/test/rewrite-test.ts +177 -24
  120. package/src/test/tree-shapes.ts +332 -0
  121. package/src/test/unparsed-sources.ts +138 -0
  122. package/src/tree.ts +3 -0
  123. package/src/visitor.ts +3 -0
@@ -19,15 +19,24 @@ import {noopVisitor, TreeVisitor} from "../visitor";
19
19
  import {Parser} from "../parser";
20
20
  import {MarkerPrinter, PrintOutputCapture, TreePrinters} from "../print";
21
21
  import {Cursor, isTree, rootCursor, SourceFile} from "../tree";
22
- import {Result, scheduleRun} from "../run";
22
+ import {ACCUMULATOR_LISTENER, Result, scheduleRun} from "../run";
23
23
  import {mapAsync, trimIndent} from "../util";
24
24
  import {isParseError, ParseErrorKind} from "../parse-error";
25
25
  import {Marker, Markers, MarkersKind, ParseExceptionResult} from "../markers";
26
26
  import {isNamedStyles} from "../style";
27
27
  import {JavaScriptVisitor} from "../javascript";
28
+ import {listenToFormatting} from "../javascript/format";
28
29
  import {attributionHint, dumpTypesIfRequested} from "../javascript/type-report";
29
30
  import {J, Type} from "../java";
30
31
  import {DATA_TABLE_STORE, InMemoryDataTableStore} from "../data-table";
32
+ import * as path from "path";
33
+ import {AccumulatorContents} from "./accumulator-contents";
34
+ import {EmptySources} from "./empty-sources";
35
+ import {UnparsedSources} from "./unparsed-sources";
36
+ import {TreeShapes} from "./tree-shapes";
37
+ import {FormattingScope} from "./formatting-scope";
38
+ import {RpcRecipe} from "../rpc/recipe";
39
+ import {DelegatingRecipe} from "../rpc/java-recipe";
31
40
 
32
41
  export interface SourceSpec<T extends SourceFile> {
33
42
  kind: string,
@@ -45,6 +54,11 @@ interface RecipeSpecConfiguration {
45
54
  checkParsePrintIdempotence: boolean;
46
55
  allowEmptyDiff: boolean;
47
56
  checkReportingMarkersOnly: boolean;
57
+ checkExecutionContextUnchanged: boolean;
58
+ checkNoEditsInScanningPhase: boolean;
59
+ checkLightweightAccumulators: boolean;
60
+ checkParserShapedTrees: boolean;
61
+ checkMinimalFormatting: boolean;
48
62
  cycles?: number;
49
63
  expectedCyclesThatMakeChanges?: number;
50
64
  recipe: Recipe;
@@ -62,6 +76,33 @@ export class RecipeSpec {
62
76
  */
63
77
  checkReportingMarkersOnly: boolean = true
64
78
 
79
+ /**
80
+ * Fails a run whose recipe puts a message on the execution context, which every recipe and source file in a run
81
+ * shares.
82
+ */
83
+ checkExecutionContextUnchanged: boolean = true
84
+
85
+ /** Fails a run whose ScanningRecipe's scanner edits a tree, which the scanning phase discards. */
86
+ checkNoEditsInScanningPhase: boolean = true
87
+
88
+ /**
89
+ * Fails a run whose ScanningRecipe's accumulator keeps a tree, cursor, visitor, execution context, dependency model
90
+ * or other type given to registerHeavyweight, which would stay in memory all cycle.
91
+ */
92
+ checkLightweightAccumulators: boolean = true
93
+
94
+ /**
95
+ * Fails a run whose output holds a node that the source's parser builds differently from the text it prints, such
96
+ * as a J.Identifier where the parser makes a J.Literal of `null`.
97
+ */
98
+ checkParserShapedTrees: boolean = true
99
+
100
+ /**
101
+ * Fails a run whose recipe auto-formats a whole source file or class that existed before the run, instead of only
102
+ * what it changed.
103
+ */
104
+ checkMinimalFormatting: boolean = true
105
+
65
106
  /**
66
107
  * The cycles to run, by default one more than {@link expectedCyclesThatMakeChanges} and at least two. The
67
108
  * cycles after the expected ones, and a run on a fresh parse of the output, fail the test if they change
@@ -114,6 +155,11 @@ export class RecipeSpec {
114
155
  checkParsePrintIdempotence: this.checkParsePrintIdempotence,
115
156
  allowEmptyDiff: this.allowEmptyDiff,
116
157
  checkReportingMarkersOnly: this.checkReportingMarkersOnly,
158
+ checkExecutionContextUnchanged: this.checkExecutionContextUnchanged,
159
+ checkNoEditsInScanningPhase: this.checkNoEditsInScanningPhase,
160
+ checkLightweightAccumulators: this.checkLightweightAccumulators,
161
+ checkParserShapedTrees: this.checkParserShapedTrees,
162
+ checkMinimalFormatting: this.checkMinimalFormatting,
117
163
  cycles: this.cycles,
118
164
  expectedCyclesThatMakeChanges: this.expectedCyclesThatMakeChanges,
119
165
  recipe: this.recipe,
@@ -217,21 +263,43 @@ export class RecipeSpec {
217
263
  }
218
264
 
219
265
  this.recipeExecutionContext.messages[DATA_TABLE_STORE] = new InMemoryDataTableStore();
266
+ this.recipeExecutionContext.messages[ExecutionContext.SCANNING_MUTATION_VALIDATION] =
267
+ this.checkNoEditsInScanningPhase;
268
+ const accumulatorContents = this.checkLightweightAccumulators ? new AccumulatorContents() : undefined;
269
+ this.recipeExecutionContext.messages[ACCUMULATOR_LISTENER] = accumulatorContents?.listener;
270
+ const contextWrites = this.checkExecutionContextUnchanged ? new ExecutionContextWrites() : undefined;
271
+ const runContext = (ctx: ExecutionContext) => contextWrites?.watch(ctx) ?? ctx;
220
272
  const expectedCycles = this.expectedCyclesThatMakeChanges ?? 1;
221
273
  const cycles = this.cycles ?? Math.max(2, expectedCycles + 1);
222
- const {changeset, unsettled} = await this.runCycles(allParsed.map(([_, sourceFile]) => sourceFile),
223
- cycles, expectedCycles);
274
+ const formattingScope = this.checkMinimalFormatting ? new FormattingScope(this.recipe) : undefined;
275
+ const run = () => this.runCycles(allParsed.map(([_, sourceFile]) => sourceFile), cycles, expectedCycles,
276
+ runContext);
277
+ const {changeset, unsettled} = formattingScope ? await listenToFormatting(formattingScope, run) : await run();
278
+ contextWrites?.assertNone(this.recipe);
224
279
 
225
280
  this.expectNoUnexpectedGeneratedFiles(flattenedSpecs, changeset);
226
281
  this.checkReportingMarkersOnly && await this.expectNoAddedMarkers(flattenedSpecs, allParsed, changeset);
282
+ accumulatorContents?.assertNoneFound();
227
283
  await this.expectResultsToMatchAfter(flattenedSpecs, changeset, allParsed);
228
284
  await this.expectGeneratedFiles(flattenedSpecs, changeset);
229
285
  if (unsettled) {
230
286
  await this.expectNoFurtherChanges(unsettled.changes,
231
287
  `Expected recipe to complete in ${pluralCycles(expectedCycles)}, but cycle ${unsettled.cycle} changed`);
232
288
  }
233
- cycles > expectedCycles && await this.expectStableOnFreshParse(flattenedSpecs, allParsed, changeset);
289
+ await formattingScope?.assertFormattedOnlyWhatChanged(changeset);
290
+ const changed = changeset.some(r => r.before !== r.after);
291
+ const freshParse = changed && (cycles > expectedCycles || this.checkParserShapedTrees) ?
292
+ await this.freshParse(flattenedSpecs, allParsed, changeset) : new Map<SourceFile, SourceFile>();
293
+ this.checkParserShapedTrees && await new TreeShapes(this.recipe).assertParserShaped(changeset, freshParse);
294
+ cycles > expectedCycles && await this.expectStableOnFreshParse(changeset, freshParse, runContext);
295
+ contextWrites?.assertNone(this.recipe);
234
296
  await this.expectDataTables();
297
+ if (changeset.some(r => r.before && r.after !== r.before) && await isProbed(this.recipe)) {
298
+ await new EmptySources(this.recipe, changeset)
299
+ .assertNoneThrow(allParsed, this.executionContext, this.recipeExecutionContext, cycles);
300
+ await new UnparsedSources(this.recipe, changeset)
301
+ .assertNoneThrow(allParsed.map(([_, sourceFile]) => sourceFile), this.recipeExecutionContext, cycles);
302
+ }
235
303
  }
236
304
 
237
305
  private async expectDataTables() {
@@ -247,7 +315,8 @@ export class RecipeSpec {
247
315
  }
248
316
  }
249
317
 
250
- private async runCycles(sources: SourceFile[], cycles: number, expectedCycles: number):
318
+ private async runCycles(sources: SourceFile[], cycles: number, expectedCycles: number,
319
+ runContext: (ctx: ExecutionContext) => ExecutionContext):
251
320
  Promise<{ changeset: Result[], unsettled?: { cycle: number, changes: Result[] } }> {
252
321
  const lineages: { origin?: SourceFile, latest?: SourceFile }[] = sources.map(s => ({origin: s, latest: s}));
253
322
  const changesetOf = () => lineages.map(({origin, latest}) => new Result(origin, latest));
@@ -255,7 +324,7 @@ export class RecipeSpec {
255
324
  let cyclesThatMadeChanges = 0;
256
325
  for (let cycle = 1; cycle <= cycles; cycle++) {
257
326
  const live = lineages.filter(l => l.latest).map(l => l.latest!);
258
- const ctx = cycle === 1 ? this.recipeExecutionContext : this.laterRunContext();
327
+ const ctx = runContext(cycle === 1 ? this.recipeExecutionContext : this.laterRunContext());
259
328
  const changes = (await scheduleRun(this.recipe, live, ctx)).changeset.filter(r => r.before !== r.after);
260
329
  if (changes.length === 0) {
261
330
  break;
@@ -319,14 +388,11 @@ export class RecipeSpec {
319
388
  }
320
389
 
321
390
  /**
322
- * Code that the next run reads is parsed from the printed output, not the LST this run produced, and a
323
- * recipe that recognizes its own output only by the shape of that LST changes it again.
391
+ * A fresh parse, by the parser of its spec, of the text each source the recipe left prints, parsing the sources
392
+ * of one kind together so that they can refer to one another. A parser that throws leaves its sources out.
324
393
  */
325
- private async expectStableOnFreshParse(specs: SourceSpec<any>[], parsed: [SourceSpec<any>, SourceFile][],
326
- changeset: Result[]) {
327
- if (!changeset.some(r => r.before !== r.after)) {
328
- return;
329
- }
394
+ private async freshParse(specs: SourceSpec<any>[], parsed: [SourceSpec<any>, SourceFile][],
395
+ changeset: Result[]): Promise<Map<SourceFile, SourceFile>> {
330
396
  const finals = changeset.filter(r => r.after).map(({before, after}): [SourceSpec<any> | undefined, SourceFile] => [
331
397
  before ? parsed.find(([_, sourceFile]) => sourceFile === before)?.[0] :
332
398
  specs.find(s => s.path === after!.sourcePath),
@@ -344,22 +410,39 @@ export class RecipeSpec {
344
410
  let i = 0;
345
411
  try {
346
412
  for await (const reparsed of group[0][0]!.parser(this.executionContext).parse(...inputs)) {
347
- if (!isParseError(reparsed) && await printSanitized(reparsed) === inputs[i].text) {
348
- // What the source file carries, like its styles, comes from outside its text
349
- reparsedByFinal.set(group[i][1], {...reparsed, markers: group[i][1].markers});
350
- }
351
- i++;
413
+ reparsedByFinal.set(group[i++][1], reparsed);
352
414
  }
353
415
  } catch {
416
+ // the sources it didn't get to are left out
417
+ }
418
+ }
419
+ return reparsedByFinal;
420
+ }
421
+
422
+ /**
423
+ * Code that the next run reads is parsed from the printed output, not the LST this run produced, and a
424
+ * recipe that recognizes its own output only by the shape of that LST changes it again.
425
+ */
426
+ private async expectStableOnFreshParse(changeset: Result[], freshParse: Map<SourceFile, SourceFile>,
427
+ runContext: (ctx: ExecutionContext) => ExecutionContext) {
428
+ let reparsedAny = false;
429
+ const sources: SourceFile[] = [];
430
+ for (const after of changeset.filter(r => r.after).map(r => r.after!)) {
431
+ const reparsed = freshParse.get(after);
432
+ if (reparsed && !isParseError(reparsed) && await printSanitized(reparsed) === await printSanitized(after)) {
433
+ // What the source file carries, like its styles, comes from outside its text
434
+ sources.push({...reparsed, markers: after.markers});
435
+ reparsedAny = true;
436
+ } else {
354
437
  // files that do not parse again keep the LST the recipe produced
438
+ sources.push(after);
355
439
  }
356
440
  }
357
- if (reparsedByFinal.size === 0) {
441
+ if (!reparsedAny) {
358
442
  return;
359
443
  }
360
444
 
361
- const sources = finals.map(([_, sourceFile]) => reparsedByFinal.get(sourceFile) ?? sourceFile);
362
- const changes = (await scheduleRun(this.recipe, sources, this.laterRunContext())).changeset
445
+ const changes = (await scheduleRun(this.recipe, sources, runContext(this.laterRunContext()))).changeset
363
446
  .filter(r => r.before !== r.after);
364
447
  await this.expectNoFurtherChanges(changes, "Running the recipe on a fresh parse of its output changed", true);
365
448
  }
@@ -501,7 +584,7 @@ export class RecipeSpec {
501
584
 
502
585
  private async expectGeneratedFiles(specs: SourceSpec<any>[], changeset: Result[]) {
503
586
  for (const spec of specs) {
504
- if (spec.before) {
587
+ if (spec.before !== null) {
505
588
  continue;
506
589
  }
507
590
  if (!spec.after) {
@@ -513,7 +596,7 @@ export class RecipeSpec {
513
596
  }
514
597
 
515
598
  private expectNoUnexpectedGeneratedFiles(specs: SourceSpec<any>[], changeset: Result[]) {
516
- const expectedPaths = new Set(specs.filter(spec => !spec.before).map(spec => spec.path));
599
+ const expectedPaths = new Set(specs.filter(spec => spec.before === null).map(spec => spec.path));
517
600
  const unexpected = changeset.filter(c => !c.before && c.after && !expectedPaths.has(c.after.sourcePath));
518
601
  if (unexpected.length > 0) {
519
602
  throw new Error([
@@ -554,7 +637,7 @@ export class RecipeSpec {
554
637
  let unnamed = 0;
555
638
  const before: [SourceSpec<any>, { text: string, sourcePath: string }][] = [];
556
639
  for (const spec of specs) {
557
- if (spec.before) {
640
+ if (spec.before !== null) {
558
641
  const sourcePath = spec.path || `source-${unnamed++}.${spec.ext}`;
559
642
  before.push([spec, {text: dedent(spec.before), sourcePath: sourcePath}]);
560
643
  }
@@ -587,6 +670,76 @@ class ValidateWhitespaceVisitor extends JavaScriptVisitor<ExecutionContext> {
587
670
  }
588
671
  }
589
672
 
673
+ /** Whether the framework puts `key` on the execution context of a run itself. */
674
+ function isFrameworkMessage(key: string | symbol): boolean {
675
+ return key === DATA_TABLE_STORE || key === ExecutionContext.REQUIRE_PRINT_EQUALS_INPUT;
676
+ }
677
+
678
+ /** Records the messages that a recipe puts on the execution context of its run, with the code that put each. */
679
+ class ExecutionContextWrites {
680
+ private readonly writes = new Map<string | symbol, string | undefined>();
681
+
682
+ /** A view of `ctx` whose messages record what is put on them, and that otherwise acts as `ctx`. */
683
+ watch(ctx: ExecutionContext): ExecutionContext {
684
+ const record = (key: string | symbol) => {
685
+ if (!isFrameworkMessage(key) && !this.writes.has(key)) {
686
+ this.writes.set(key, callerFrame());
687
+ }
688
+ };
689
+ const messages = new Proxy(ctx.messages, {
690
+ set(target, key, value) {
691
+ record(key);
692
+ return Reflect.set(target, key, value);
693
+ },
694
+ defineProperty(target, key, attributes) {
695
+ record(key);
696
+ return Reflect.defineProperty(target, key, attributes);
697
+ }
698
+ });
699
+ return Object.create(ctx, {messages: {value: messages, enumerable: true, writable: true}});
700
+ }
701
+
702
+ assertNone(recipe: Recipe): void {
703
+ if (this.writes.size === 0) {
704
+ return;
705
+ }
706
+ throw new Error([
707
+ "Keep state that spans source files in a ScanningRecipe's accumulator, and state within one source file " +
708
+ "in cursor messages, with this.cursor.messages.set(key, value). " +
709
+ `${recipe.name} put messages on the ExecutionContext, which every recipe and source file in a run ` +
710
+ "shares, so what it keeps there leaks into the others:",
711
+ ...[...this.writes].map(([key, frame]) =>
712
+ ` ${typeof key === "symbol" ? key.toString() : JSON.stringify(key)}${frame ? ` at ${frame}` : ""}`),
713
+ "A recipe that must change the ExecutionContext opts out with spec.checkExecutionContextUnchanged = false."
714
+ ].join("\n"));
715
+ }
716
+ }
717
+
718
+ /** The first frame of the stack outside the harness and the runtime, relative to the working directory. */
719
+ function callerFrame(): string | undefined {
720
+ const frame = (new Error().stack ?? "").split("\n").slice(1)
721
+ .map(line => line.trim().replace(/^at (async )?/, ""))
722
+ .find(line => !/[\\/]test[\\/]rewrite-test\.[jt]s:|<anonymous>|^node:/.test(line));
723
+ return frame?.split(process.cwd() + path.sep).join("");
724
+ }
725
+
726
+ /**
727
+ * Whether the recipe is run on sources the test doesn't give it, which a recipe isn't when it or one in its recipe list
728
+ * is the harness's stand-in for a visitor, or runs in Java, whose harness probes it.
729
+ */
730
+ async function isProbed(recipe: Recipe): Promise<boolean> {
731
+ if (recipe instanceof NoopRecipe || recipe instanceof AdHocRecipe || recipe instanceof RpcRecipe ||
732
+ recipe instanceof DelegatingRecipe) {
733
+ return false;
734
+ }
735
+ for (const child of await recipe.recipeList()) {
736
+ if (!await isProbed(child)) {
737
+ return false;
738
+ }
739
+ }
740
+ return true;
741
+ }
742
+
590
743
  /** Without the comments that search results and markup print as. */
591
744
  function printSanitized(sourceFile: SourceFile): Promise<string> {
592
745
  return TreePrinters.printer(sourceFile).print(sourceFile, new PrintOutputCapture(MarkerPrinter.SANITIZED));
@@ -0,0 +1,332 @@
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 {Recipe} from "../recipe";
17
+ import {Cursor, isTree, rootCursor, SourceFile, Tree} from "../tree";
18
+ import {Result} from "../run";
19
+ import {MarkerPrinter, PrintOutputCapture, TreePrinters} from "../print";
20
+ import {isParseError} from "../parse-error";
21
+ import {MarkersKind, ParseExceptionResult} from "../markers";
22
+ import {TreeVisitor} from "../visitor";
23
+ import {J} from "../java";
24
+ import {JavaScriptVisitor, JS} from "../javascript";
25
+ import {Json, JsonVisitor} from "../json";
26
+ import {Yaml, YamlVisitor} from "../yaml";
27
+ import {typeName} from "./probes";
28
+
29
+ const MAX_PER_FILE = 5;
30
+ const SNIPPET_LENGTH = 60;
31
+ const STRICT_NUMBER = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
32
+
33
+ /** How the check reads the trees of a language, as Java's `TreeShapeValidationService` does. */
34
+ interface Language {
35
+ /** The visitor of the language, whose order of visiting the check compares nodes in. */
36
+ readonly visitor: abstract new () => TreeVisitor<any, any>
37
+
38
+ /** What tells a node apart from another of its kind with the same children, such as an identifier's name. */
39
+ value(tree: Tree): string | undefined
40
+
41
+ /** Whether the comparison leaves out a node and what it holds, as with an empty element the parser adds. */
42
+ skip(tree: Tree): boolean
43
+
44
+ /** What a node has that the parser accepts but the language doesn't, such as a duplicate key. */
45
+ invalid(tree: Tree): string[]
46
+ }
47
+
48
+ let languages: { [kind: string]: Language } | undefined;
49
+
50
+ /**
51
+ * The language of a source file of `kind`, from a table built on first use, since the modules of the languages import
52
+ * the harness and may still be loading when it loads.
53
+ */
54
+ function languageOf(kind: string): Language | undefined {
55
+ languages ??= {
56
+ [JS.Kind.CompilationUnit]: {
57
+ visitor: JavaScriptVisitor,
58
+ value: javaScriptValue,
59
+ skip: tree => tree.kind === J.Kind.Empty,
60
+ invalid: () => []
61
+ },
62
+ [Json.Kind.Document]: {
63
+ visitor: JsonVisitor,
64
+ value: jsonValue,
65
+ skip: tree => tree.kind === Json.Kind.Empty,
66
+ invalid: jsonInvalid
67
+ },
68
+ [Yaml.Kind.Documents]: {
69
+ visitor: YamlVisitor,
70
+ value: yamlValue,
71
+ skip: () => false,
72
+ invalid: yamlInvalid
73
+ }
74
+ };
75
+ return languages[kind];
76
+ }
77
+
78
+ interface Node {
79
+ readonly tree: Tree
80
+ readonly cursor: Cursor
81
+ readonly children: Node[]
82
+ }
83
+
84
+ /**
85
+ * Finds nodes in a recipe's output that its parser builds differently from the text they print, which recipes that
86
+ * run after it don't match the way they match parsed code.
87
+ */
88
+ export class TreeShapes {
89
+ constructor(private readonly recipe: Recipe) {
90
+ }
91
+
92
+ /** @param freshParse The fresh parse of the text each source the recipe left prints. */
93
+ async assertParserShaped(results: Result[], freshParse: Map<SourceFile, SourceFile>): Promise<void> {
94
+ const found: string[] = [];
95
+ for (const {before, after} of results) {
96
+ const language = after && languageOf(after.kind);
97
+ if (!after || after === before || !language) {
98
+ continue;
99
+ }
100
+ const text = await printed(after);
101
+ const reparsed = freshParse.get(after);
102
+ if (!reparsed || text.trim() === "" || before && await printed(before) === text) {
103
+ continue;
104
+ }
105
+ const problems = await this.problems(language, after, before, reparsed, text);
106
+ if (problems.length > 0) {
107
+ found.push(` ${after.sourcePath}:`, ...problems.map(problem => ` ${problem}`));
108
+ }
109
+ }
110
+ if (found.length > 0) {
111
+ throw new Error([
112
+ "Write code with a template or the format's own parser, or build the nodes the way its parser " +
113
+ `builds the text they print. The output of ${this.recipe.name} holds nodes that its parser makes ` +
114
+ "differently of the same text:",
115
+ ...found,
116
+ "A recipe that runs after it matches the tree, not the text, so it misses or mistakes a node that " +
117
+ "parsed code never holds. If the output is meant to differ from parsed code, set " +
118
+ "spec.checkParserShapedTrees = false."
119
+ ].join("\n"));
120
+ }
121
+ }
122
+
123
+ private async problems(language: Language, after: SourceFile, before: SourceFile | undefined,
124
+ reparsed: SourceFile, text: string): Promise<string[]> {
125
+ if (isParseError(reparsed)) {
126
+ const exception = reparsed.markers.markers
127
+ .find(m => m.kind === MarkersKind.ParseExceptionResult) as ParseExceptionResult | undefined;
128
+ return [`the text it prints doesn't parse: ${exception ? firstLine(exception.message) : "a ParseError"}`];
129
+ } else if (reparsed.kind !== after.kind || await printed(reparsed) !== text) {
130
+ // a parser that doesn't print back what it read is the parser's to fix
131
+ return [];
132
+ }
133
+ const problems: string[] = [];
134
+ const shape = await skeleton(language, after);
135
+ await compare(language, after, shape, await skeleton(language, reparsed), problems);
136
+ // a file that already broke its language's rules follows other ones
137
+ if (!before || invalid(language, await skeleton(language, before)).length === 0) {
138
+ for (const problem of invalid(language, shape)) {
139
+ if (problems.length < MAX_PER_FILE) {
140
+ problems.push(problem);
141
+ }
142
+ }
143
+ }
144
+ return problems;
145
+ }
146
+ }
147
+
148
+ async function compare(language: Language, sourceFile: SourceFile, node: Node, parsed: Node,
149
+ problems: string[]): Promise<void> {
150
+ if (problems.length >= MAX_PER_FILE) {
151
+ return;
152
+ }
153
+ if (node.tree.kind !== parsed.tree.kind) {
154
+ problems.push(`${await snippet(sourceFile, node)} is a ${typeName(node.tree.kind)} where the parser makes a ` +
155
+ typeName(parsed.tree.kind));
156
+ return;
157
+ }
158
+ const value = language.value(node.tree);
159
+ const parsedValue = language.value(parsed.tree);
160
+ if (value !== parsedValue) {
161
+ problems.push(`${await snippet(sourceFile, node)} has ${value} where the parser makes ${parsedValue}`);
162
+ }
163
+ if (node.children.length !== parsed.children.length) {
164
+ problems.push(`${await snippet(sourceFile, node)} holds ${kinds(node.children)} where the parser makes ` +
165
+ kinds(parsed.children));
166
+ return;
167
+ }
168
+ for (let i = 0; i < node.children.length; i++) {
169
+ await compare(language, sourceFile, node.children[i], parsed.children[i], problems);
170
+ }
171
+ }
172
+
173
+ function kinds(nodes: Node[]): string {
174
+ return nodes.length === 0 ? "nothing" : nodes.map(node => typeName(node.tree.kind)).join(", ");
175
+ }
176
+
177
+ /** The nodes of a source file, each with the nodes it holds, leaving out those the language skips. */
178
+ async function skeleton(language: Language, sourceFile: SourceFile): Promise<Node> {
179
+ const roots: Node[] = [];
180
+ const path: Node[] = [];
181
+ const Visitor = language.visitor as unknown as new () => TreeVisitor<any, any>;
182
+ const visitor = new class extends Visitor {
183
+ protected override async preVisit(tree: Tree, _p: any): Promise<Tree | undefined> {
184
+ if (language.skip(tree)) {
185
+ this.stopAfterPreVisit();
186
+ return tree;
187
+ }
188
+ const ancestors = this.cursor.asArray().filter(value => isTree(value)).length - 1;
189
+ path.length = Math.min(path.length, ancestors);
190
+ const node: Node = {tree, cursor: this.cursor, children: []};
191
+ (path.length === 0 ? roots : path[path.length - 1].children).push(node);
192
+ path.push(node);
193
+ return tree;
194
+ }
195
+ };
196
+ await visitor.visit(sourceFile, undefined);
197
+ return roots[0] ?? {tree: sourceFile, cursor: rootCursor(), children: []};
198
+ }
199
+
200
+ function invalid(language: Language, node: Node): string[] {
201
+ return [...language.invalid(node.tree), ...node.children.flatMap(child => invalid(language, child))];
202
+ }
203
+
204
+ async function snippet(sourceFile: SourceFile, node: Node): Promise<string> {
205
+ let shown: string;
206
+ try {
207
+ shown = (await TreePrinters.printer(sourceFile)
208
+ .print(node.tree, new PrintOutputCapture(MarkerPrinter.SANITIZED), node.cursor.parent))
209
+ .trim().replace(/\s+/g, " ");
210
+ } catch {
211
+ shown = "?";
212
+ }
213
+ return `\`${shown.length > SNIPPET_LENGTH ? `${shown.substring(0, SNIPPET_LENGTH)}...` : shown}\``;
214
+ }
215
+
216
+ function javaScriptValue(tree: Tree): string | undefined {
217
+ switch (tree.kind) {
218
+ case J.Kind.Identifier:
219
+ return `the name \`${(tree as J.Identifier).simpleName}\``;
220
+ case J.Kind.Literal: {
221
+ const literal = tree as J.Literal;
222
+ // the parser keeps the source of a literal with surrogate escapes in place of its value
223
+ const value = literal.unicodeEscapes?.length ? "a value with surrogate escapes" :
224
+ literal.value === null || literal.value === undefined ? `the value ${literal.value}` :
225
+ `the ${typeof literal.value} value ${quoted(literal.value)}`;
226
+ return `${value} of type ${literal.type?.keyword ?? "none"}`;
227
+ }
228
+ case J.Kind.Modifier: {
229
+ const modifier = tree as J.Modifier;
230
+ return `the modifier ${modifier.type === J.ModifierType.LanguageExtension ? modifier.keyword : modifier.type}`;
231
+ }
232
+ case J.Kind.Primitive:
233
+ return `the type ${(tree as J.Primitive).type.keyword}`;
234
+ case J.Kind.Binary:
235
+ case J.Kind.Unary:
236
+ case J.Kind.AssignmentOperation:
237
+ case JS.Kind.Binary:
238
+ case JS.Kind.AssignmentOperation:
239
+ return `the operator ${(tree as J.Binary).operator.element}`;
240
+ case JS.Kind.TypeOperator:
241
+ return `the operator ${(tree as JS.TypeOperator).operator}`;
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ function quoted(value: string | number | bigint | boolean): string {
247
+ let shown = String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r")
248
+ .replace(/\t/g, "\\t");
249
+ if (shown.length > 40) {
250
+ shown = `${shown.substring(0, 40)}...`;
251
+ }
252
+ return typeof value === "string" ? `"${shown}"` : shown;
253
+ }
254
+
255
+ function jsonValue(tree: Tree): string | undefined {
256
+ if (tree.kind === Json.Kind.Literal) {
257
+ const value = (tree as Json.Literal).value;
258
+ return value === null || value === undefined ? `the value ${value}` : `the ${typeof value} value \`${value}\``;
259
+ } else if (tree.kind === Json.Kind.Identifier) {
260
+ return `the name \`${(tree as Json.Identifier).name}\``;
261
+ }
262
+ return undefined;
263
+ }
264
+
265
+ function jsonInvalid(tree: Tree): string[] {
266
+ const found: string[] = [];
267
+ if (tree.kind === Json.Kind.Object) {
268
+ const keys = new Set<string>();
269
+ for (const member of (tree as Json.Object).members.map(m => m.element)) {
270
+ if (member.kind === Json.Kind.Member) {
271
+ const key = (member as Json.Member).key.element;
272
+ const name = key.kind === Json.Kind.Identifier ? key.name : String(key.value);
273
+ if (keys.has(name)) {
274
+ found.push(`the key \`${name}\` appears twice in one object`);
275
+ }
276
+ keys.add(name);
277
+ }
278
+ }
279
+ } else if (tree.kind === Json.Kind.Member) {
280
+ const key = (tree as Json.Member).key.element;
281
+ if (key.kind === Json.Kind.Identifier) {
282
+ found.push(`the key \`${key.name}\` has no quotes, which JSON doesn't allow`);
283
+ }
284
+ } else if (tree.kind === Json.Kind.Literal) {
285
+ const {source, value} = tree as Json.Literal;
286
+ if (source.startsWith("'")) {
287
+ found.push(`\`${source}\` is in single quotes, which JSON doesn't allow`);
288
+ } else if (typeof value === "number" && !STRICT_NUMBER.test(source)) {
289
+ found.push(`\`${source}\` isn't a number JSON allows`);
290
+ }
291
+ }
292
+ return found;
293
+ }
294
+
295
+ function yamlValue(tree: Tree): string | undefined {
296
+ if (tree.kind === Yaml.Kind.Scalar) {
297
+ const scalar = tree as Yaml.Scalar;
298
+ // a block scalar's value holds the whitespace up to the next node, which the parser splits differently
299
+ const block = scalar.style === Yaml.ScalarStyle.LITERAL || scalar.style === Yaml.ScalarStyle.FOLDED;
300
+ return `the ${scalar.style} value \`${block ? scalar.value.replace(/\s+$/, "") : scalar.value}\``;
301
+ } else if (tree.kind === Yaml.Kind.Anchor) {
302
+ return `the anchor \`${(tree as Yaml.Anchor).key}\``;
303
+ } else if (tree.kind === Yaml.Kind.Alias) {
304
+ return `the alias of \`${(tree as Yaml.Alias).anchor.key}\``;
305
+ }
306
+ return undefined;
307
+ }
308
+
309
+ function yamlInvalid(tree: Tree): string[] {
310
+ if (tree.kind !== Yaml.Kind.Mapping) {
311
+ return [];
312
+ }
313
+ const found: string[] = [];
314
+ const keys = new Set<string>();
315
+ for (const {key} of (tree as Yaml.Mapping).entries) {
316
+ const name = key.kind === Yaml.Kind.Scalar ? (key as Yaml.Scalar).value : `*${(key as Yaml.Alias).anchor.key}`;
317
+ if (keys.has(name)) {
318
+ found.push(`the key \`${name}\` appears twice in one mapping, which YAML doesn't allow`);
319
+ }
320
+ keys.add(name);
321
+ }
322
+ return found;
323
+ }
324
+
325
+ function printed(sourceFile: SourceFile): Promise<string> {
326
+ return TreePrinters.printer(sourceFile).print(sourceFile, new PrintOutputCapture(MarkerPrinter.SANITIZED));
327
+ }
328
+
329
+ function firstLine(message: string): string {
330
+ const line = message.trim().split("\n")[0];
331
+ return line.length > 200 ? `${line.substring(0, 200)}...` : line;
332
+ }