@openrewrite/rewrite 8.69.0-20251207-092603 → 8.69.0-20251207-135957

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.
@@ -37,17 +37,19 @@ import {
37
37
  } from './cli-utils';
38
38
  import {ValidateParsingRecipe} from './validate-parsing-recipe';
39
39
 
40
- const isTTY = process.stdout.isTTY ?? false;
40
+ const isTTY = process.stderr.isTTY ?? false;
41
41
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
42
42
 
43
43
  class Spinner {
44
44
  private frameIndex = 0;
45
45
  private interval: NodeJS.Timeout | null = null;
46
46
  private message = '';
47
+ private running = false;
47
48
 
48
49
  start(message: string): void {
49
50
  if (!isTTY) return;
50
51
  this.message = message;
52
+ this.running = true;
51
53
  this.render();
52
54
  this.interval = setInterval(() => this.render(), 80);
53
55
  }
@@ -61,16 +63,34 @@ class Spinner {
61
63
  private render(): void {
62
64
  const frame = SPINNER_FRAMES[this.frameIndex];
63
65
  this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
64
- process.stdout.write(`\r\x1b[K${frame} ${this.message}`);
66
+ process.stderr.write(`\r\x1b[K${frame} ${this.message}`);
67
+ }
68
+
69
+ /**
70
+ * Temporarily clear the spinner line for other output.
71
+ * Call resume() to restore it.
72
+ */
73
+ clear(): void {
74
+ if (!isTTY || !this.running) return;
75
+ process.stderr.write('\r\x1b[K');
76
+ }
77
+
78
+ /**
79
+ * Resume the spinner after clear().
80
+ */
81
+ resume(): void {
82
+ if (!isTTY || !this.running) return;
83
+ this.render();
65
84
  }
66
85
 
67
86
  stop(): void {
87
+ this.running = false;
68
88
  if (this.interval) {
69
89
  clearInterval(this.interval);
70
90
  this.interval = null;
71
91
  }
72
92
  if (isTTY) {
73
- process.stdout.write('\r\x1b[K');
93
+ process.stderr.write('\r\x1b[K');
74
94
  }
75
95
  }
76
96
  }
@@ -182,14 +202,10 @@ async function main() {
182
202
  }
183
203
 
184
204
  // Handle special built-in recipes
185
- let recipe: Recipe;
186
- let validateParsingRecipe: ValidateParsingRecipe | undefined;
205
+ let recipe: Recipe | undefined;
206
+ const isValidateParsing = recipeArg === 'validate-parsing';
187
207
 
188
- if (recipeArg === 'validate-parsing') {
189
- // Special recipe to validate parsing and check idempotence
190
- validateParsingRecipe = new ValidateParsingRecipe();
191
- recipe = validateParsingRecipe;
192
- } else {
208
+ if (!isValidateParsing) {
193
209
  // Parse recipe specification
194
210
  const recipeSpec = parseRecipeSpec(recipeArg);
195
211
  if (!recipeSpec) {
@@ -234,10 +250,10 @@ async function main() {
234
250
  process.exit(1);
235
251
  }
236
252
  recipe = foundRecipe;
237
- }
238
253
 
239
- if (opts.verbose) {
240
- console.log(`Running recipe: ${recipe.name}`);
254
+ if (opts.verbose) {
255
+ console.log(`Running recipe: ${recipe.name}`);
256
+ }
241
257
  }
242
258
 
243
259
  const spinner = new Spinner();
@@ -309,9 +325,20 @@ async function main() {
309
325
  console.log(`Found ${uniqueSourceFiles.length} source files`);
310
326
  }
311
327
 
312
- // Set project root for validate-parsing recipe if active
313
- if (validateParsingRecipe) {
314
- validateParsingRecipe.setProjectRoot(projectRoot);
328
+ // Create validate-parsing recipe now that we know the project root
329
+ if (isValidateParsing) {
330
+ const validateRecipe = new ValidateParsingRecipe({projectRoot});
331
+ // Set up reporting callback that coordinates with spinner
332
+ validateRecipe.onReport = (message: string) => {
333
+ if (!opts.verbose) {
334
+ spinner.clear();
335
+ }
336
+ console.log(message);
337
+ if (!opts.verbose) {
338
+ spinner.resume();
339
+ }
340
+ };
341
+ recipe = validateRecipe;
315
342
  }
316
343
 
317
344
  // Create streaming parser - files are parsed on-demand as they're consumed
@@ -337,7 +364,7 @@ async function main() {
337
364
  spinner.start(`Processing ${totalFiles} files...`);
338
365
  }
339
366
 
340
- for await (const result of scheduleRunStreaming(recipe, sourceFileStream, ctx, onProgress)) {
367
+ for await (const result of scheduleRunStreaming(recipe!, sourceFileStream, ctx, onProgress)) {
341
368
  processedCount++;
342
369
  const currentPath = result.after?.sourcePath ?? result.before?.sourcePath ?? '';
343
370
 
@@ -405,11 +432,11 @@ async function main() {
405
432
  if (!opts.verbose) spinner.stop();
406
433
 
407
434
  // For validate-parsing recipe, check for errors and exit accordingly
408
- if (validateParsingRecipe) {
409
- if (!validateParsingRecipe.hasErrors) {
435
+ if (recipe instanceof ValidateParsingRecipe) {
436
+ if (!recipe.hasErrors) {
410
437
  console.log('All files parsed successfully.');
411
438
  }
412
- process.exit(validateParsingRecipe.hasErrors ? 1 : 0);
439
+ process.exit(recipe.hasErrors ? 1 : 0);
413
440
  }
414
441
 
415
442
  if (changeCount === 0) {
@@ -13,7 +13,7 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import {Recipe} from '../recipe';
16
+ import {Option, Recipe} from '../recipe';
17
17
  import {TreeVisitor} from '../visitor';
18
18
  import {ExecutionContext} from '../execution';
19
19
  import {SourceFile, Tree} from '../tree';
@@ -24,6 +24,12 @@ import {createTwoFilesPatch} from 'diff';
24
24
  import * as fs from 'fs';
25
25
  import * as path from 'path';
26
26
 
27
+ /**
28
+ * Callback for reporting validation results.
29
+ * @param message The message to report
30
+ */
31
+ export type ReportCallback = (message: string) => void;
32
+
27
33
  /**
28
34
  * A recipe that validates parsing by:
29
35
  * 1. Reporting parse errors to stderr
@@ -36,13 +42,27 @@ export class ValidateParsingRecipe extends Recipe {
36
42
  displayName = 'Validate parsing';
37
43
  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
44
 
39
- private projectRoot: string = process.cwd();
45
+ @Option({
46
+ displayName: 'Project root',
47
+ description: 'The root directory of the project, used to resolve source file paths for idempotence checking.',
48
+ required: false
49
+ })
50
+ projectRoot!: string;
51
+
52
+ /**
53
+ * Optional callback for reporting messages. If not set, uses console.log.
54
+ */
55
+ onReport?: ReportCallback;
56
+
40
57
  private parseErrorCount = 0;
41
58
  private idempotenceFailureCount = 0;
42
59
 
43
- setProjectRoot(root: string): this {
44
- this.projectRoot = root;
45
- return this;
60
+ private report(message: string): void {
61
+ if (this.onReport) {
62
+ this.onReport(message);
63
+ } else {
64
+ console.log(message);
65
+ }
46
66
  }
47
67
 
48
68
  async editor(): Promise<TreeVisitor<any, ExecutionContext>> {
@@ -59,7 +79,7 @@ export class ValidateParsingRecipe extends Recipe {
59
79
  m => m.kind === MarkersKind.ParseExceptionResult
60
80
  ) as ParseExceptionResult | undefined;
61
81
  const message = parseException?.message ?? 'Unknown parse error';
62
- console.error(`Parse error in ${sourceFile.sourcePath}: ${message}`);
82
+ recipe.report(`Parse error in ${sourceFile.sourcePath}: ${message}`);
63
83
  recipe.parseErrorCount++;
64
84
  return tree as R;
65
85
  }
@@ -72,7 +92,7 @@ export class ValidateParsingRecipe extends Recipe {
72
92
 
73
93
  if (printed !== original) {
74
94
  recipe.idempotenceFailureCount++;
75
- console.error(`Parse-to-print idempotence failure in ${sourceFile.sourcePath}:`);
95
+ recipe.report(`Parse-to-print idempotence failure in ${sourceFile.sourcePath}:`);
76
96
 
77
97
  // Generate and print diff
78
98
  const diff = createTwoFilesPatch(
@@ -84,11 +104,11 @@ export class ValidateParsingRecipe extends Recipe {
84
104
  'printed',
85
105
  {context: 3}
86
106
  );
87
- console.error(diff);
107
+ recipe.report(diff);
88
108
  }
89
109
  } catch (e: any) {
90
110
  recipe.idempotenceFailureCount++;
91
- console.error(`Failed to check idempotence for ${sourceFile.sourcePath}: ${e.message}`);
111
+ recipe.report(`Failed to check idempotence for ${sourceFile.sourcePath}: ${e.message}`);
92
112
  }
93
113
 
94
114
  return tree as R;
@@ -98,12 +118,12 @@ export class ValidateParsingRecipe extends Recipe {
98
118
 
99
119
  async onComplete(_ctx: ExecutionContext): Promise<void> {
100
120
  if (this.parseErrorCount > 0 || this.idempotenceFailureCount > 0) {
101
- console.error('');
121
+ this.report('');
102
122
  if (this.parseErrorCount > 0) {
103
- console.error(`${this.parseErrorCount} file(s) had parse errors.`);
123
+ this.report(`${this.parseErrorCount} file(s) had parse errors.`);
104
124
  }
105
125
  if (this.idempotenceFailureCount > 0) {
106
- console.error(`${this.idempotenceFailureCount} file(s) had parse-to-print idempotence failures.`);
126
+ this.report(`${this.idempotenceFailureCount} file(s) had parse-to-print idempotence failures.`);
107
127
  }
108
128
  }
109
129
  }
@@ -21,6 +21,7 @@ import {JavaScriptVisitor} from "../visitor";
21
21
  import {J} from "../../java";
22
22
  import {JS} from "../tree";
23
23
  import {produce} from "immer";
24
+ import {findMarker} from "../../markers";
24
25
 
25
26
  /**
26
27
  * Simplifies object properties where the key and value have the same name,
@@ -121,7 +122,13 @@ export class UseObjectPropertyShorthand extends Recipe {
121
122
 
122
123
  // Check if the initializer is also an identifier with the same name
123
124
  if (prop.initializer?.kind === J.Kind.Identifier) {
124
- const initName = (prop.initializer as J.Identifier).simpleName;
125
+ const init = prop.initializer as J.Identifier;
126
+ const initName = init.simpleName;
127
+
128
+ // Skip if initializer has non-null assertion marker
129
+ if (findMarker(init, JS.Markers.NonNullAssertion)) {
130
+ return stmt;
131
+ }
125
132
 
126
133
  if (propName === initName) {
127
134
  hasChanges = true;
@@ -370,8 +370,8 @@ export class JavaScriptParserVisitor {
370
370
  if (prefix.whitespace?.startsWith('#!')) {
371
371
  const newlineIndex = prefix.whitespace.indexOf('\n');
372
372
  const shebangText = newlineIndex === -1 ? prefix.whitespace : prefix.whitespace.slice(0, newlineIndex);
373
- const afterShebang = newlineIndex === -1 ? '' : '\n';
374
- const remainingWhitespace = newlineIndex === -1 ? '' : prefix.whitespace.slice(newlineIndex + 1);
373
+ // Include all whitespace after shebang (including blank lines) in the shebang's after space
374
+ const afterShebang = newlineIndex === -1 ? '' : prefix.whitespace.slice(newlineIndex);
375
375
 
376
376
  shebangStatement = this.rightPadded<JS.Shebang>({
377
377
  kind: JS.Kind.Shebang,
@@ -381,8 +381,9 @@ export class JavaScriptParserVisitor {
381
381
  text: shebangText
382
382
  }, {kind: J.Kind.Space, whitespace: afterShebang, comments: []}, emptyMarkers);
383
383
 
384
+ // CU prefix should be empty when there's a shebang
384
385
  prefix = produce(prefix, draft => {
385
- draft.whitespace = remainingWhitespace;
386
+ draft.whitespace = '';
386
387
  });
387
388
  }
388
389
 
@@ -3704,7 +3705,7 @@ export class JavaScriptParserVisitor {
3704
3705
  id: randomId(),
3705
3706
  prefix: this.prefix(node),
3706
3707
  markers: emptyMarkers,
3707
- openName: this.leftPadded(this.prefix(node.openingElement), this.visit(node.openingElement.tagName)),
3708
+ openName: this.leftPadded(this.prefix(node.openingElement.tagName), this.visit(node.openingElement.tagName)),
3708
3709
  typeArguments: node.openingElement.typeArguments && this.mapTypeArguments(this.suffix(node.openingElement.tagName), node.openingElement.typeArguments),
3709
3710
  afterName: attrs.length === 0 ?
3710
3711
  this.prefix(this.findLastChildNode(node.openingElement, ts.SyntaxKind.GreaterThanToken)!) :
@@ -97,7 +97,10 @@ export class JavaScriptPrinter extends JavaScriptVisitor<PrintOutputCapture> {
97
97
 
98
98
  override async visitJsxTag(element: JSX.Tag, p: PrintOutputCapture): Promise<J | undefined> {
99
99
  await this.beforeSyntax(element, p);
100
- await this.visitLeftPaddedLocal("<", element.openName, p);
100
+ // Print < first, then the space after < (openName.before), then the tag name
101
+ p.append("<");
102
+ await this.visitSpace(element.openName.before, p);
103
+ await this.visit(element.openName.element, p);
101
104
  if (element.typeArguments) {
102
105
  await this.visitContainerLocal("<", element.typeArguments, ",", ">", p);
103
106
  }
@@ -113,7 +116,10 @@ export class JavaScriptPrinter extends JavaScriptVisitor<PrintOutputCapture> {
113
116
  for (let i = 0; i < element.children.length; i++) {
114
117
  await this.visit(element.children[i], p)
115
118
  }
116
- await this.visitLeftPaddedLocal("</", element.closingName, p);
119
+ // Print </ first, then the space after </ (closingName.before), then the tag name
120
+ p.append("</");
121
+ await this.visitSpace(element.closingName!.before, p);
122
+ await this.visit(element.closingName!.element, p);
117
123
  await this.visitSpace(element.afterClosingName, p);
118
124
  p.append(">");
119
125
  }
@@ -13,20 +13,40 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import {emptyMarkers} from "../markers";
17
- import {Parser, ParserInput, ParserSourceReader} from "../parser";
16
+ import {emptyMarkers, markers, MarkersKind, ParseExceptionResult} from "../markers";
17
+ import {Parser, ParserInput, parserInputRead, ParserSourceReader} from "../parser";
18
18
  import {randomId} from "../uuid";
19
19
  import {SourceFile} from "../tree";
20
20
  import {emptySpace, Json, space} from "./tree";
21
+ import {ParseError, ParseErrorKind} from "../parse-error";
21
22
 
22
23
  export class JsonParser extends Parser {
23
24
 
24
25
  async *parse(...sourcePaths: ParserInput[]): AsyncGenerator<SourceFile> {
25
26
  for (const sourcePath of sourcePaths) {
26
- yield {
27
- ...new ParseJsonReader(sourcePath).parse(),
28
- sourcePath: this.relativePath(sourcePath)
29
- };
27
+ try {
28
+ yield {
29
+ ...new ParseJsonReader(sourcePath).parse(),
30
+ sourcePath: this.relativePath(sourcePath)
31
+ };
32
+ } catch (e: any) {
33
+ // Return a ParseError for files that can't be parsed (e.g., JSONC with comments)
34
+ const text = parserInputRead(sourcePath);
35
+ const parseError: ParseError = {
36
+ kind: ParseErrorKind,
37
+ id: randomId(),
38
+ markers: markers({
39
+ kind: MarkersKind.ParseExceptionResult,
40
+ id: randomId(),
41
+ parserType: "JsonParser",
42
+ exceptionType: e.name || "Error",
43
+ message: e.message || "Unknown parse error"
44
+ } satisfies ParseExceptionResult as ParseExceptionResult),
45
+ sourcePath: this.relativePath(sourcePath),
46
+ text
47
+ };
48
+ yield parseError;
49
+ }
30
50
  }
31
51
  }
32
52
  }
package/src/run.ts CHANGED
@@ -145,7 +145,10 @@ export async function* scheduleRunStreaming(
145
145
  } else {
146
146
  // For non-scanning recipes, process files immediately as they come in
147
147
  const iterable = Array.isArray(before) ? before : before;
148
+ let processCount = 0;
148
149
  for await (const b of iterable) {
150
+ processCount++;
151
+ onProgress?.('processing', processCount, -1, b.sourcePath);
149
152
  const editedB = await recurseRecipeList(recipe, b, async (recipe, b2) => (await recipe.editor()).visit(b2, ctx, cursor));
150
153
  // Always yield a result so the caller knows when each file is processed
151
154
  yield new Result(b, editedB !== b ? editedB : b);