@openrewrite/rewrite 8.69.0-20251206-160244 → 8.69.0-20251207-092603

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 (36) hide show
  1. package/dist/cli/cli-utils.d.ts +12 -2
  2. package/dist/cli/cli-utils.d.ts.map +1 -1
  3. package/dist/cli/cli-utils.js +53 -13
  4. package/dist/cli/cli-utils.js.map +1 -1
  5. package/dist/cli/rewrite.d.ts.map +1 -1
  6. package/dist/cli/rewrite.js +230 -82
  7. package/dist/cli/rewrite.js.map +1 -1
  8. package/dist/cli/validate-parsing-recipe.d.ts +23 -0
  9. package/dist/cli/validate-parsing-recipe.d.ts.map +1 -0
  10. package/dist/cli/validate-parsing-recipe.js +149 -0
  11. package/dist/cli/validate-parsing-recipe.js.map +1 -0
  12. package/dist/javascript/format.d.ts.map +1 -1
  13. package/dist/javascript/format.js +7 -2
  14. package/dist/javascript/format.js.map +1 -1
  15. package/dist/javascript/parser.d.ts.map +1 -1
  16. package/dist/javascript/parser.js +4 -2
  17. package/dist/javascript/parser.js.map +1 -1
  18. package/dist/javascript/tabs-and-indents-visitor.d.ts.map +1 -1
  19. package/dist/javascript/tabs-and-indents-visitor.js +32 -4
  20. package/dist/javascript/tabs-and-indents-visitor.js.map +1 -1
  21. package/dist/json/parser.js +35 -20
  22. package/dist/json/parser.js.map +1 -1
  23. package/dist/run.d.ts +8 -1
  24. package/dist/run.d.ts.map +1 -1
  25. package/dist/run.js +78 -21
  26. package/dist/run.js.map +1 -1
  27. package/dist/version.txt +1 -1
  28. package/package.json +1 -1
  29. package/src/cli/cli-utils.ts +28 -9
  30. package/src/cli/rewrite.ts +244 -85
  31. package/src/cli/validate-parsing-recipe.ts +114 -0
  32. package/src/javascript/format.ts +7 -2
  33. package/src/javascript/parser.ts +6 -2
  34. package/src/javascript/tabs-and-indents-visitor.ts +33 -3
  35. package/src/json/parser.ts +35 -20
  36. package/src/run.ts +61 -25
@@ -15,12 +15,13 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import {Command} from 'commander';
18
+ import * as fs from 'fs';
18
19
  import * as fsp from 'fs/promises';
19
20
  import * as path from 'path';
20
21
  import {spawn} from 'child_process';
21
- import {RecipeRegistry} from '../recipe';
22
+ import {Recipe, RecipeRegistry} from '../recipe';
22
23
  import {ExecutionContext} from '../execution';
23
- import {scheduleRunStreaming} from '../run';
24
+ import {ProgressCallback, scheduleRunStreaming} from '../run';
24
25
  import {TreePrinters} from '../print';
25
26
  import {activate} from '../index';
26
27
  import {
@@ -28,11 +29,13 @@ import {
28
29
  discoverFiles,
29
30
  findRecipe,
30
31
  installRecipePackage,
32
+ isAcceptedFile,
31
33
  loadLocalRecipes,
32
- parseFiles,
34
+ parseFilesStreaming,
33
35
  parseRecipeOptions,
34
36
  parseRecipeSpec
35
37
  } from './cli-utils';
38
+ import {ValidateParsingRecipe} from './validate-parsing-recipe';
36
39
 
37
40
  const isTTY = process.stdout.isTTY ?? false;
38
41
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -83,21 +86,80 @@ require('v8').setFlagsFromString('--stack-size=8000');
83
86
 
84
87
  const DEFAULT_RECIPE_DIR = path.join(process.env.HOME || '', '.rewrite', 'javascript', 'recipes');
85
88
 
89
+ /**
90
+ * Check if a directory is a git repository root.
91
+ */
92
+ function isGitRoot(dir: string): boolean {
93
+ return fs.existsSync(path.join(dir, '.git'));
94
+ }
95
+
96
+ /**
97
+ * Find the project root by scanning upward for a directory containing package.json.
98
+ * Stops at git repository root or filesystem root.
99
+ * Returns the starting directory if no package.json is found.
100
+ */
101
+ function findProjectRoot(startDir: string): string {
102
+ let dir = path.resolve(startDir);
103
+ while (true) {
104
+ const packageJsonPath = path.join(dir, 'package.json');
105
+ if (fs.existsSync(packageJsonPath)) {
106
+ return dir;
107
+ }
108
+ // Stop at git root - don't scan beyond the repository
109
+ if (isGitRoot(dir)) {
110
+ return dir;
111
+ }
112
+ const parent = path.dirname(dir);
113
+ if (parent === dir) {
114
+ // Reached filesystem root, return original directory
115
+ return path.resolve(startDir);
116
+ }
117
+ dir = parent;
118
+ }
119
+ }
120
+
86
121
  interface CliOptions {
87
- dryRun: boolean;
122
+ apply: boolean;
123
+ list: boolean;
88
124
  recipeDir?: string;
89
125
  option: string[];
90
126
  verbose: boolean;
91
127
  debug: boolean;
92
128
  }
93
129
 
130
+ /**
131
+ * Convert a project-root-relative path to a CWD-relative path for display.
132
+ */
133
+ function toCwdRelative(sourcePath: string, projectRoot: string): string {
134
+ const absolutePath = path.join(projectRoot, sourcePath);
135
+ return path.relative(process.cwd(), absolutePath) || '.';
136
+ }
137
+
138
+ /**
139
+ * Transform diff output to use CWD-relative paths in headers.
140
+ */
141
+ function transformDiffPaths(diff: string, projectRoot: string): string {
142
+ return diff.replace(
143
+ /^(---|\+\+\+) (.+)$/gm,
144
+ (_match, prefix, filePath) => {
145
+ if (filePath) {
146
+ const cwdRelative = toCwdRelative(filePath, projectRoot);
147
+ return `${prefix} ${cwdRelative}`;
148
+ }
149
+ return _match;
150
+ }
151
+ );
152
+ }
153
+
94
154
  async function main() {
95
155
  const program = new Command();
96
156
  program
97
157
  .name('rewrite')
98
158
  .description('Run OpenRewrite recipes on your codebase')
99
159
  .argument('<recipe>', 'Recipe to run in format "package:recipe" (e.g., "@openrewrite/recipes-nodejs:replace-deprecated-slice")')
100
- .option('-n, --dry-run', 'Show what changes would be made without applying them', false)
160
+ .argument('[paths...]', 'Files or directories to process (defaults to project root)')
161
+ .option('--apply', 'Apply changes to files (default is dry-run showing diffs)', false)
162
+ .option('-l, --list', 'Only list paths of files that would be changed', false)
101
163
  .option('-d, --recipe-dir <dir>', 'Directory for recipe installation', DEFAULT_RECIPE_DIR)
102
164
  .option('-o, --option <option...>', 'Recipe options in format "key=value"', [])
103
165
  .option('-v, --verbose', 'Enable verbose output', false)
@@ -105,6 +167,7 @@ async function main() {
105
167
  .parse();
106
168
 
107
169
  const recipeArg = program.args[0];
170
+ const pathArgs = program.args.slice(1);
108
171
  const opts = program.opts() as CliOptions;
109
172
  opts.option = opts.option || [];
110
173
 
@@ -118,47 +181,59 @@ async function main() {
118
181
  return;
119
182
  }
120
183
 
121
- // Parse recipe specification
122
- const recipeSpec = parseRecipeSpec(recipeArg);
123
- if (!recipeSpec) {
124
- console.error(`Invalid recipe format: ${recipeArg}`);
125
- console.error('Expected format: "package:recipe" (e.g., "@openrewrite/recipes-nodejs:replace-deprecated-slice")');
126
- process.exit(1);
127
- }
184
+ // Handle special built-in recipes
185
+ let recipe: Recipe;
186
+ let validateParsingRecipe: ValidateParsingRecipe | undefined;
128
187
 
129
- if (opts.verbose) {
130
- console.log(`Package: ${recipeSpec.packageName}`);
131
- console.log(`Recipe: ${recipeSpec.recipeName}`);
132
- }
188
+ if (recipeArg === 'validate-parsing') {
189
+ // Special recipe to validate parsing and check idempotence
190
+ validateParsingRecipe = new ValidateParsingRecipe();
191
+ recipe = validateParsingRecipe;
192
+ } else {
193
+ // Parse recipe specification
194
+ const recipeSpec = parseRecipeSpec(recipeArg);
195
+ if (!recipeSpec) {
196
+ console.error(`Invalid recipe format: ${recipeArg}`);
197
+ console.error('Expected format: "package:recipe" (e.g., "@openrewrite/recipes-nodejs:replace-deprecated-slice")');
198
+ console.error('Or use "validate-parsing" to check for parse errors and idempotence.');
199
+ process.exit(1);
200
+ }
133
201
 
134
- // Parse recipe options
135
- const recipeOptions = parseRecipeOptions(opts.option);
136
- if (opts.verbose && Object.keys(recipeOptions).length > 0) {
137
- console.log(`Options: ${JSON.stringify(recipeOptions)}`);
138
- }
202
+ if (opts.verbose) {
203
+ console.log(`Package: ${recipeSpec.packageName}`);
204
+ console.log(`Recipe: ${recipeSpec.recipeName}`);
205
+ }
206
+
207
+ // Parse recipe options
208
+ const recipeOptions = parseRecipeOptions(opts.option);
209
+ if (opts.verbose && Object.keys(recipeOptions).length > 0) {
210
+ console.log(`Options: ${JSON.stringify(recipeOptions)}`);
211
+ }
139
212
 
140
- // Set up recipe registry
141
- const registry = new RecipeRegistry();
213
+ // Set up recipe registry
214
+ const registry = new RecipeRegistry();
142
215
 
143
- // Register built-in recipes
144
- await activate(registry);
216
+ // Register built-in recipes
217
+ await activate(registry);
145
218
 
146
- // Load recipes from local path or install from NPM
147
- try {
148
- if (recipeSpec.isLocalPath) {
149
- await loadLocalRecipes(recipeSpec.packageName, registry, opts.verbose);
150
- } else {
151
- await installRecipePackage(recipeSpec.packageName, opts.recipeDir || DEFAULT_RECIPE_DIR, registry, opts.verbose);
219
+ // Load recipes from local path or install from NPM
220
+ try {
221
+ if (recipeSpec.isLocalPath) {
222
+ await loadLocalRecipes(recipeSpec.packageName, registry, opts.verbose);
223
+ } else {
224
+ await installRecipePackage(recipeSpec.packageName, opts.recipeDir || DEFAULT_RECIPE_DIR, registry, opts.verbose);
225
+ }
226
+ } catch (e: any) {
227
+ console.error(`Failed to load recipes: ${e.message}`);
228
+ process.exit(1);
152
229
  }
153
- } catch (e: any) {
154
- console.error(`Failed to load recipes: ${e.message}`);
155
- process.exit(1);
156
- }
157
230
 
158
- // Find the recipe
159
- const recipe = findRecipe(registry, recipeSpec.recipeName, recipeOptions);
160
- if (!recipe) {
161
- process.exit(1);
231
+ // Find the recipe
232
+ const foundRecipe = findRecipe(registry, recipeSpec.recipeName, recipeOptions);
233
+ if (!foundRecipe) {
234
+ process.exit(1);
235
+ }
236
+ recipe = foundRecipe;
162
237
  }
163
238
 
164
239
  if (opts.verbose) {
@@ -166,63 +241,115 @@ async function main() {
166
241
  }
167
242
 
168
243
  const spinner = new Spinner();
169
- const projectRoot = process.cwd();
170
244
 
171
- // Discover source files
172
- spinner.start('Discovering source files...');
173
- const sourceFiles = await discoverFiles(projectRoot, opts.verbose);
245
+ // Determine paths to process and project root
246
+ let targetPaths: string[];
247
+ let projectRoot: string;
248
+
249
+ if (pathArgs.length > 0) {
250
+ // Explicit paths provided - find project root and validate they're all in the same project
251
+ targetPaths = pathArgs.map(p => path.resolve(p));
252
+ const projectRoots = new Set<string>();
253
+ for (const targetPath of targetPaths) {
254
+ const pathDir = fs.statSync(targetPath).isDirectory() ? targetPath : path.dirname(targetPath);
255
+ projectRoots.add(findProjectRoot(pathDir));
256
+ }
257
+
258
+ if (projectRoots.size > 1) {
259
+ console.error('Error: Specified paths belong to different projects:');
260
+ for (const root of projectRoots) {
261
+ console.error(` - ${root}`);
262
+ }
263
+ console.error('\nAll paths must be within the same project (share a common package.json root).');
264
+ process.exit(1);
265
+ }
266
+
267
+ projectRoot = projectRoots.values().next().value!;
268
+
269
+ if (opts.verbose) {
270
+ if (targetPaths.length > 1) {
271
+ console.log(`Processing ${targetPaths.length} paths`);
272
+ }
273
+ console.log(`Project root: ${projectRoot}`);
274
+ }
275
+ } else {
276
+ // No paths provided - find project root from CWD and process entire project
277
+ projectRoot = findProjectRoot(process.cwd());
278
+ targetPaths = [projectRoot];
279
+
280
+ if (opts.verbose) {
281
+ console.log(`Processing entire project: ${projectRoot}`);
282
+ }
283
+ }
284
+
285
+ // Discover source files from all specified paths
286
+ if (!opts.verbose) {
287
+ spinner.start('Discovering source files...');
288
+ }
289
+ const sourceFiles: string[] = [];
290
+ for (const targetPath of targetPaths) {
291
+ const stat = await fsp.stat(targetPath);
292
+ if (stat.isDirectory()) {
293
+ const files = await discoverFiles(targetPath, opts.verbose);
294
+ sourceFiles.push(...files);
295
+ } else if (stat.isFile() && isAcceptedFile(targetPath)) {
296
+ sourceFiles.push(targetPath);
297
+ }
298
+ }
299
+ // Remove duplicates (in case of overlapping paths)
300
+ const uniqueSourceFiles = [...new Set(sourceFiles)];
174
301
  spinner.stop();
175
302
 
176
- if (sourceFiles.length === 0) {
303
+ if (uniqueSourceFiles.length === 0) {
177
304
  console.log('No source files found to process.');
178
305
  return;
179
306
  }
180
307
 
181
308
  if (opts.verbose) {
182
- console.log(`Found ${sourceFiles.length} source files`);
309
+ console.log(`Found ${uniqueSourceFiles.length} source files`);
183
310
  }
184
311
 
185
- // Parse all source files
186
- spinner.start(`Parsing ${sourceFiles.length} files...`);
187
- const parsedFiles = await parseFiles(sourceFiles, projectRoot, opts.verbose, (current, total, filePath) => {
188
- spinner.update(`Parsing [${current}/${total}] ${filePath}`);
189
- });
190
- spinner.stop();
191
-
192
- if (parsedFiles.length === 0) {
193
- console.log('No files could be parsed.');
194
- return;
312
+ // Set project root for validate-parsing recipe if active
313
+ if (validateParsingRecipe) {
314
+ validateParsingRecipe.setProjectRoot(projectRoot);
195
315
  }
196
316
 
197
- // Run the recipe with streaming output
317
+ // Create streaming parser - files are parsed on-demand as they're consumed
318
+ const totalFiles = uniqueSourceFiles.length;
319
+ const sourceFileStream = parseFilesStreaming(uniqueSourceFiles, projectRoot, {
320
+ verbose: opts.verbose
321
+ });
322
+
323
+ // Run the recipe with streaming output - for non-scanning recipes,
324
+ // parsing and processing happen concurrently without collecting all files first
198
325
  const ctx = new ExecutionContext();
199
326
  let changeCount = 0;
200
327
  let processedCount = 0;
201
- const totalFiles = parsedFiles.length;
202
328
 
203
- spinner.start(`Running recipe on ${totalFiles} files...`);
329
+ // Progress callback for spinner updates during all phases (disabled in verbose mode)
330
+ const onProgress: ProgressCallback | undefined = opts.verbose ? undefined : (phase, current, total, sourcePath) => {
331
+ const totalStr = total > 0 ? total : totalFiles;
332
+ const phaseLabel = phase.charAt(0).toUpperCase() + phase.slice(1);
333
+ spinner.update(`${phaseLabel} [${current}/${totalStr}] ${sourcePath}`);
334
+ };
335
+
336
+ if (!opts.verbose) {
337
+ spinner.start(`Processing ${totalFiles} files...`);
338
+ }
204
339
 
205
- for await (const result of scheduleRunStreaming(recipe, parsedFiles, ctx)) {
340
+ for await (const result of scheduleRunStreaming(recipe, sourceFileStream, ctx, onProgress)) {
206
341
  processedCount++;
207
342
  const currentPath = result.after?.sourcePath ?? result.before?.sourcePath ?? '';
208
- spinner.update(`Processing [${processedCount}/${totalFiles}] ${currentPath}`);
209
343
 
210
344
  const statusMsg = `Processing [${processedCount}/${totalFiles}] ${currentPath}`;
211
345
 
212
- if (opts.dryRun) {
213
- // Print colorized diff immediately (skip empty diffs)
214
- const diff = await result.diff();
215
- const hasChanges = diff.split('\n').some(line =>
216
- (line.startsWith('+') || line.startsWith('-')) &&
217
- !line.startsWith('+++') && !line.startsWith('---')
218
- );
219
- if (hasChanges) {
220
- changeCount++;
221
- spinner.stop();
222
- console.log(colorizeDiff(diff));
223
- spinner.start(statusMsg);
224
- }
225
- } else {
346
+ // Skip unchanged files - first check object identity (fast path),
347
+ // then check actual diff content for files where visitor returned new object
348
+ if (result.before === result.after) {
349
+ continue; // Definitely unchanged
350
+ }
351
+
352
+ if (opts.apply) {
226
353
  // Apply changes immediately
227
354
  if (result.after) {
228
355
  const filePath = path.join(projectRoot, result.after.sourcePath);
@@ -233,32 +360,64 @@ async function main() {
233
360
  await fsp.writeFile(filePath, content);
234
361
 
235
362
  changeCount++;
236
- spinner.stop();
363
+ if (!opts.verbose) spinner.stop();
364
+ const displayPath = toCwdRelative(result.after.sourcePath, projectRoot);
237
365
  if (result.before) {
238
- console.log(` Modified: ${result.after.sourcePath}`);
366
+ console.log(` Modified: ${displayPath}`);
239
367
  } else {
240
- console.log(` Created: ${result.after.sourcePath}`);
368
+ console.log(` Created: ${displayPath}`);
241
369
  }
242
- spinner.start(statusMsg);
370
+ if (!opts.verbose) spinner.start(statusMsg);
243
371
  } else if (result.before) {
244
372
  const filePath = path.join(projectRoot, result.before.sourcePath);
245
373
  await fsp.unlink(filePath);
246
374
  changeCount++;
247
- spinner.stop();
248
- console.log(` Deleted: ${result.before.sourcePath}`);
249
- spinner.start(statusMsg);
375
+ if (!opts.verbose) spinner.stop();
376
+ console.log(` Deleted: ${toCwdRelative(result.before.sourcePath, projectRoot)}`);
377
+ if (!opts.verbose) spinner.start(statusMsg);
378
+ }
379
+ } else {
380
+ // Dry-run mode: show diff or just list paths
381
+ const diff = await result.diff();
382
+ const hasChanges = diff.split('\n').some(line =>
383
+ (line.startsWith('+') || line.startsWith('-')) &&
384
+ !line.startsWith('+++') && !line.startsWith('---')
385
+ );
386
+ if (hasChanges) {
387
+ changeCount++;
388
+ if (!opts.verbose) spinner.stop();
389
+ if (opts.list) {
390
+ // Just list the path
391
+ const displayPath = toCwdRelative(
392
+ result.after?.sourcePath ?? result.before?.sourcePath ?? '',
393
+ projectRoot
394
+ );
395
+ console.log(displayPath);
396
+ } else {
397
+ // Show the diff with CWD-relative paths
398
+ console.log(colorizeDiff(transformDiffPaths(diff, projectRoot)));
399
+ }
400
+ if (!opts.verbose) spinner.start(statusMsg);
250
401
  }
251
402
  }
252
403
  }
253
404
 
254
- spinner.stop();
405
+ if (!opts.verbose) spinner.stop();
406
+
407
+ // For validate-parsing recipe, check for errors and exit accordingly
408
+ if (validateParsingRecipe) {
409
+ if (!validateParsingRecipe.hasErrors) {
410
+ console.log('All files parsed successfully.');
411
+ }
412
+ process.exit(validateParsingRecipe.hasErrors ? 1 : 0);
413
+ }
255
414
 
256
415
  if (changeCount === 0) {
257
416
  console.log('No changes to make.');
258
- } else if (opts.dryRun) {
259
- console.log(`\n${changeCount} file(s) would be changed.`);
260
- } else {
417
+ } else if (opts.apply) {
261
418
  console.log(`\n${changeCount} file(s) changed.`);
419
+ } else if (!opts.list) {
420
+ console.log(`\n${changeCount} file(s) would be changed. Run with --apply to apply changes.`);
262
421
  }
263
422
  }
264
423
 
@@ -0,0 +1,114 @@
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 {TreeVisitor} from '../visitor';
18
+ import {ExecutionContext} from '../execution';
19
+ import {SourceFile, Tree} from '../tree';
20
+ import {isParseError} from '../parse-error';
21
+ import {MarkersKind, ParseExceptionResult} from '../markers';
22
+ import {TreePrinters} from '../print';
23
+ import {createTwoFilesPatch} from 'diff';
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+
27
+ /**
28
+ * A recipe that validates parsing by:
29
+ * 1. Reporting parse errors to stderr
30
+ * 2. Checking parse-to-print idempotence and showing diffs for failures
31
+ *
32
+ * Use this recipe to validate that the parser correctly handles your codebase.
33
+ */
34
+ export class ValidateParsingRecipe extends Recipe {
35
+ name = 'org.openrewrite.validate-parsing';
36
+ displayName = 'Validate parsing';
37
+ description = 'Validates that all source files parse correctly and that parse-to-print is idempotent. Reports parse errors and shows diffs for idempotence failures.';
38
+
39
+ private projectRoot: string = process.cwd();
40
+ private parseErrorCount = 0;
41
+ private idempotenceFailureCount = 0;
42
+
43
+ setProjectRoot(root: string): this {
44
+ this.projectRoot = root;
45
+ return this;
46
+ }
47
+
48
+ async editor(): Promise<TreeVisitor<any, ExecutionContext>> {
49
+ const recipe = this;
50
+ return new class extends TreeVisitor<Tree, ExecutionContext> {
51
+ async visit<R extends Tree>(tree: Tree | undefined, ctx: ExecutionContext): Promise<R | undefined> {
52
+ if (!tree) return undefined;
53
+
54
+ const sourceFile = tree as SourceFile;
55
+
56
+ // Check for parse errors
57
+ if (isParseError(sourceFile)) {
58
+ const parseException = sourceFile.markers.markers.find(
59
+ m => m.kind === MarkersKind.ParseExceptionResult
60
+ ) as ParseExceptionResult | undefined;
61
+ const message = parseException?.message ?? 'Unknown parse error';
62
+ console.error(`Parse error in ${sourceFile.sourcePath}: ${message}`);
63
+ recipe.parseErrorCount++;
64
+ return tree as R;
65
+ }
66
+
67
+ // Check parse-to-print idempotence
68
+ try {
69
+ const printed = await TreePrinters.print(sourceFile);
70
+ const originalPath = path.join(recipe.projectRoot, sourceFile.sourcePath);
71
+ const original = fs.readFileSync(originalPath, 'utf-8');
72
+
73
+ if (printed !== original) {
74
+ recipe.idempotenceFailureCount++;
75
+ console.error(`Parse-to-print idempotence failure in ${sourceFile.sourcePath}:`);
76
+
77
+ // Generate and print diff
78
+ const diff = createTwoFilesPatch(
79
+ sourceFile.sourcePath,
80
+ sourceFile.sourcePath,
81
+ original,
82
+ printed,
83
+ 'original',
84
+ 'printed',
85
+ {context: 3}
86
+ );
87
+ console.error(diff);
88
+ }
89
+ } catch (e: any) {
90
+ recipe.idempotenceFailureCount++;
91
+ console.error(`Failed to check idempotence for ${sourceFile.sourcePath}: ${e.message}`);
92
+ }
93
+
94
+ return tree as R;
95
+ }
96
+ };
97
+ }
98
+
99
+ async onComplete(_ctx: ExecutionContext): Promise<void> {
100
+ if (this.parseErrorCount > 0 || this.idempotenceFailureCount > 0) {
101
+ console.error('');
102
+ if (this.parseErrorCount > 0) {
103
+ console.error(`${this.parseErrorCount} file(s) had parse errors.`);
104
+ }
105
+ if (this.idempotenceFailureCount > 0) {
106
+ console.error(`${this.idempotenceFailureCount} file(s) had parse-to-print idempotence failures.`);
107
+ }
108
+ }
109
+ }
110
+
111
+ get hasErrors(): boolean {
112
+ return this.parseErrorCount > 0 || this.idempotenceFailureCount > 0;
113
+ }
114
+ }
@@ -464,8 +464,13 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
464
464
  }
465
465
  return produceAsync(ret, async draft => {
466
466
  if (draft.initializer) {
467
- draft.initializer.before.whitespace = this.style.aroundOperators.assignment ? " " : "";
468
- draft.initializer.element.prefix.whitespace = this.style.aroundOperators.assignment ? " " : "";
467
+ // Preserve newlines - only modify if no newlines present
468
+ if (!draft.initializer.before.whitespace.includes("\n")) {
469
+ draft.initializer.before.whitespace = this.style.aroundOperators.assignment ? " " : "";
470
+ }
471
+ if (!draft.initializer.element.prefix.whitespace.includes("\n")) {
472
+ draft.initializer.element.prefix.whitespace = this.style.aroundOperators.assignment ? " " : "";
473
+ }
469
474
  }
470
475
  });
471
476
  }
@@ -1764,7 +1764,9 @@ export class JavaScriptParserVisitor {
1764
1764
  prefix: this.prefix(node),
1765
1765
  markers: emptyMarkers,
1766
1766
  head: this.visit(node.head),
1767
- spans: node.templateSpans.map(s => this.rightPadded(this.visit(s), this.suffix(s))),
1767
+ // Use emptySpace for the last span's after - any trailing whitespace belongs to the outer context
1768
+ spans: node.templateSpans.map((s, i, arr) =>
1769
+ this.rightPadded(this.visit(s), i === arr.length - 1 ? emptySpace : this.suffix(s))),
1768
1770
  type: this.mapType(node)
1769
1771
  }
1770
1772
  }
@@ -2496,7 +2498,9 @@ export class JavaScriptParserVisitor {
2496
2498
  prefix: this.prefix(node),
2497
2499
  markers: emptyMarkers,
2498
2500
  head: this.visit(node.head),
2499
- spans: node.templateSpans.map(s => this.rightPadded(this.visit(s), this.suffix(s))),
2501
+ // Use emptySpace for the last span's after - any trailing whitespace belongs to the outer context
2502
+ spans: node.templateSpans.map((s, i, arr) =>
2503
+ this.rightPadded(this.visit(s), i === arr.length - 1 ? emptySpace : this.suffix(s))),
2500
2504
  type: this.mapType(node)
2501
2505
  }
2502
2506
  }
@@ -43,7 +43,19 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
43
43
  const [parentMyIndent, parentIndentKind] = this.getParentIndentContext(cursor);
44
44
  const myIndent = this.computeMyIndent(tree, parentMyIndent, parentIndentKind);
45
45
  cursor.messages.set("myIndent", myIndent);
46
- cursor.messages.set("indentKind", this.computeIndentKind(tree));
46
+
47
+ // For Binary, behavior depends on whether it's already on a continuation line
48
+ if (tree.kind === J.Kind.Binary) {
49
+ const hasNewline = tree.prefix?.whitespace?.includes("\n") ||
50
+ tree.prefix?.comments?.some(c => c.suffix.includes("\n"));
51
+ // If Binary has newline, children align. Otherwise, children get continuation.
52
+ cursor.messages.set("indentKind", hasNewline ? 'align' : 'continuation');
53
+ // For LeftPadded children (like operator), same rule applies
54
+ cursor.messages.set("leftPaddedContinuation", hasNewline ? 'align' : 'propagate');
55
+ } else {
56
+ cursor.messages.set("indentKind", this.computeIndentKind(tree));
57
+ cursor.messages.set("leftPaddedContinuation", 'propagate');
58
+ }
47
59
  }
48
60
 
49
61
  private getParentIndentContext(cursor: Cursor): [string, IndentKind] {
@@ -167,11 +179,29 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
167
179
  left: J.LeftPadded<T>,
168
180
  p: P
169
181
  ): Promise<J.LeftPadded<T> | undefined> {
182
+ const parentIndent = this.cursor.messages.get("myIndent") as string ?? "";
183
+ const continuation = this.cursor.messages.get("leftPaddedContinuation") as string ?? 'propagate';
184
+ const hasNewline = left.before.whitespace.includes("\n");
185
+ const shouldPropagate = hasNewline && continuation === 'propagate';
186
+
187
+ // For 'propagate' mode, update myIndent for children so nested structures get proper indent
188
+ const savedMyIndent = this.cursor.messages.get("myIndent");
189
+ if (shouldPropagate) {
190
+ this.cursor.messages.set("myIndent", parentIndent + this.singleIndent);
191
+ }
192
+
170
193
  const ret = await super.visitLeftPadded(left, p);
171
- if (ret === undefined || !ret.before.whitespace.includes("\n")) {
194
+
195
+ // Restore myIndent
196
+ if (savedMyIndent !== undefined) {
197
+ this.cursor.messages.set("myIndent", savedMyIndent);
198
+ } else if (shouldPropagate) {
199
+ this.cursor.messages.delete("myIndent");
200
+ }
201
+
202
+ if (ret === undefined || !hasNewline) {
172
203
  return ret;
173
204
  }
174
- const parentIndent = this.cursor.messages.get("myIndent") as string ?? "";
175
205
  return produce(ret, draft => {
176
206
  draft.before.whitespace = this.combineIndent(draft.before.whitespace, parentIndent + this.singleIndent);
177
207
  });