@openrewrite/rewrite 8.69.0-20251207-230203 → 8.69.0-20251208-174001

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.
@@ -13,12 +13,25 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import {isJavaScript, JS, JSX} from "./tree";
16
+ import {JS, JSX} from "./tree";
17
17
  import {JavaScriptVisitor} from "./visitor";
18
- import {isJava, J, lastWhitespace, replaceLastWhitespace} from "../java";
18
+ import {
19
+ isJava,
20
+ isSpace,
21
+ J,
22
+ lastWhitespace,
23
+ normalizeSpaceIndent,
24
+ replaceIndentAfterLastNewline,
25
+ replaceLastWhitespace,
26
+ spaceContainsNewline,
27
+ stripLeadingIndent
28
+ } from "../java";
19
29
  import {produce} from "immer";
20
- import {Cursor, isScope, Tree} from "../tree";
30
+ import {Cursor, isScope, isTree, Tree} from "../tree";
31
+ import {mapAsync} from "../util";
32
+ import {produceAsync} from "../visitor";
21
33
  import {TabsAndIndentsStyle} from "./style";
34
+ import {findMarker} from "../markers";
22
35
 
23
36
  type IndentKind = 'block' | 'continuation' | 'align';
24
37
  export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
@@ -48,43 +61,95 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
48
61
  if (tree.kind === J.Kind.Binary) {
49
62
  const hasNewline = tree.prefix?.whitespace?.includes("\n") ||
50
63
  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');
64
+ // If Binary has newline OR parent is in align mode, children align
65
+ const shouldAlign = hasNewline || parentIndentKind === 'align';
66
+ cursor.messages.set("indentKind", shouldAlign ? 'align' : 'continuation');
55
67
  } else {
56
68
  cursor.messages.set("indentKind", this.computeIndentKind(tree));
57
- cursor.messages.set("leftPaddedContinuation", 'propagate');
58
69
  }
59
70
  }
60
71
 
61
72
  private getParentIndentContext(cursor: Cursor): [string, IndentKind] {
73
+ // Find the nearest myIndent and the nearest indentKind separately
74
+ // Container/RightPadded/LeftPadded inherit myIndent but don't have indentKind
75
+ let parentIndent: string | undefined;
76
+ let parentKind: IndentKind | undefined;
77
+
62
78
  for (let c = cursor.parent; c != null; c = c.parent) {
63
- const indent = c.messages.get("myIndent") as string | undefined;
64
- if (indent !== undefined) {
65
- const kind = c.messages.get("indentKind") as IndentKind ?? 'continuation';
66
- return [indent, kind];
79
+ if (parentIndent === undefined) {
80
+ const indent = c.messages.get("myIndent") as string | undefined;
81
+ if (indent !== undefined) {
82
+ parentIndent = indent;
83
+ }
84
+ }
85
+
86
+ if (parentKind === undefined) {
87
+ const kind = c.messages.get("indentKind") as IndentKind | undefined;
88
+ if (kind !== undefined) {
89
+ parentKind = kind;
90
+ }
91
+ }
92
+
93
+ // Found both, we can stop
94
+ if (parentIndent !== undefined && parentKind !== undefined) {
95
+ break;
67
96
  }
68
97
  }
69
- return ["", 'continuation'];
98
+
99
+ return [parentIndent ?? "", parentKind ?? 'continuation'];
70
100
  }
71
101
 
72
102
  private computeMyIndent(tree: J, parentMyIndent: string, parentIndentKind: IndentKind): string {
103
+ // CompilationUnit is the root - it always has myIndent="" regardless of prefix content
104
+ if (tree.kind === JS.Kind.CompilationUnit) {
105
+ return "";
106
+ }
73
107
  if (tree.kind === J.Kind.IfElse || parentIndentKind === 'align') {
74
108
  return parentMyIndent;
75
109
  }
76
- if (parentIndentKind === 'block') {
77
- return parentMyIndent + this.singleIndent;
110
+ // Only add indent if this element starts on a new line
111
+ // Check both the element's prefix and any Spread marker's prefix
112
+ const hasNewline = this.prefixContainsNewline(tree);
113
+ if (!hasNewline) {
114
+ // Special case for JSX: children of JsxTag don't have newlines in their prefix
115
+ // (newlines are in text Literal nodes), but nested tags should still get block indent
116
+ if (this.isNestedJsxTag(tree)) {
117
+ return parentMyIndent + this.singleIndent;
118
+ }
119
+ return parentMyIndent;
78
120
  }
79
- const hasNewline = tree.prefix?.whitespace?.includes("\n") ||
80
- tree.prefix?.comments?.some(c => c.suffix.includes("\n"));
81
- return hasNewline ? parentMyIndent + this.singleIndent : parentMyIndent;
121
+ // Add indent for block children or continuation
122
+ return parentMyIndent + this.singleIndent;
123
+ }
124
+
125
+ private prefixContainsNewline(tree: J): boolean {
126
+ // Check the element's own prefix
127
+ if (tree.prefix?.whitespace?.includes("\n") ||
128
+ tree.prefix?.comments?.some(c => c.suffix.includes("\n"))) {
129
+ return true;
130
+ }
131
+ // For elements with Spread marker, check the Spread marker's prefix
132
+ const spreadMarker = tree.markers?.markers?.find(m => m.kind === JS.Markers.Spread) as { prefix: J.Space } | undefined;
133
+ if (spreadMarker && spaceContainsNewline(spreadMarker.prefix)) {
134
+ return true;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ private isNestedJsxTag(tree: J): boolean {
140
+ // Check if this is a JsxTag whose parent is also a JsxTag
141
+ if (tree.kind !== JS.Kind.JsxTag) {
142
+ return false;
143
+ }
144
+ const parentTree = this.cursor.parentTree();
145
+ return parentTree !== undefined && parentTree.value.kind === JS.Kind.JsxTag;
82
146
  }
83
147
 
84
148
  private computeIndentKind(tree: J): IndentKind {
85
149
  switch (tree.kind) {
86
150
  case J.Kind.Block:
87
151
  case J.Kind.Case:
152
+ case JS.Kind.JsxTag:
88
153
  return 'block';
89
154
  case JS.Kind.CompilationUnit:
90
155
  return 'align';
@@ -104,109 +169,345 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
104
169
  }
105
170
 
106
171
  let result = tree;
107
- if (result.prefix?.whitespace?.includes("\n")) {
108
- result = produce(result, draft => {
109
- draft.prefix!.whitespace = this.combineIndent(draft.prefix!.whitespace, myIndent);
110
- });
172
+
173
+ // Check if the element has a Spread marker - if so, normalize its prefix instead
174
+ const spreadMarker = result.markers?.markers?.find(m => m.kind === JS.Markers.Spread) as { prefix: J.Space } | undefined;
175
+ if (spreadMarker && spaceContainsNewline(spreadMarker.prefix)) {
176
+ const normalizedPrefix = normalizeSpaceIndent(spreadMarker.prefix, myIndent);
177
+ if (normalizedPrefix !== spreadMarker.prefix) {
178
+ result = produce(result, draft => {
179
+ const spreadIdx = draft.markers.markers.findIndex(m => m.kind === JS.Markers.Spread);
180
+ if (spreadIdx !== -1) {
181
+ (draft.markers.markers[spreadIdx] as any).prefix = normalizedPrefix;
182
+ }
183
+ });
184
+ }
185
+ } else if (result.prefix && spaceContainsNewline(result.prefix)) {
186
+ // Normalize the entire prefix space including comment suffixes
187
+ const normalizedPrefix = normalizeSpaceIndent(result.prefix, myIndent);
188
+ if (normalizedPrefix !== result.prefix) {
189
+ result = produce(result, draft => {
190
+ draft.prefix = normalizedPrefix;
191
+ });
192
+ }
111
193
  }
112
194
 
113
195
  if (result.kind === J.Kind.Block) {
114
196
  result = this.normalizeBlockEnd(result as J.Block, myIndent);
115
- } else if (result.kind === JS.Kind.JsxTag) {
116
- result = this.normalizeJsxTagEnd(result as JSX.Tag, myIndent);
197
+ } else if (result.kind === J.Kind.Literal && this.isInsideJsxTag()) {
198
+ result = this.normalizeJsxTextContent(result as J.Literal, myIndent);
117
199
  }
118
200
 
119
201
  return result;
120
202
  }
121
203
 
122
- private normalizeBlockEnd(block: J.Block, myIndent: string): J.Block {
123
- const effectiveLastWs = lastWhitespace(block.end);
124
- if (!effectiveLastWs.includes("\n")) {
125
- return block;
204
+ private isInsideJsxTag(): boolean {
205
+ const parentTree = this.cursor.parentTree();
206
+ return parentTree !== undefined && parentTree.value.kind === JS.Kind.JsxTag;
207
+ }
208
+
209
+ private normalizeJsxTextContent(literal: J.Literal, myIndent: string): J.Literal {
210
+ if (!literal.valueSource || !literal.valueSource.includes("\n")) {
211
+ return literal;
126
212
  }
127
- return produce(block, draft => {
128
- draft.end = replaceLastWhitespace(draft.end, ws => this.combineIndent(ws, myIndent));
213
+
214
+ // Check if this literal is the last child of a JsxTag - if so, its trailing whitespace
215
+ // should use the parent tag's indent, not the content indent
216
+ const parentIndent = this.cursor.parentTree()!.messages.get("myIndent") as string | undefined;
217
+ const isLastChild = parentIndent !== undefined && this.isLastChildOfJsxTag(literal);
218
+
219
+ // For JSX text content, the newline is in the value, not the prefix.
220
+ // Since the content IS effectively on a new line, it should get block child indent.
221
+ // myIndent is the parent's indent (because Literal prefix has no newline),
222
+ // so we need to add singleIndent for content lines.
223
+ const contentIndent = myIndent + this.singleIndent;
224
+
225
+ // Split by newlines and normalize each line's indentation
226
+ const lines = literal.valueSource.split('\n');
227
+ const result: string[] = [];
228
+
229
+ for (let i = 0; i < lines.length; i++) {
230
+ if (i === 0) {
231
+ // Content before first newline stays as-is
232
+ result.push(lines[i]);
233
+ continue;
234
+ }
235
+
236
+ const content = stripLeadingIndent(lines[i]);
237
+
238
+ if (content === '') {
239
+ // Line has only whitespace (or is empty)
240
+ if (isLastChild && i === lines.length - 1) {
241
+ // Trailing whitespace of last child - use parent indent for closing tag alignment
242
+ result.push(parentIndent!);
243
+ } else if (i < lines.length - 1) {
244
+ // Empty line in the middle (followed by more lines) - keep empty
245
+ result.push('');
246
+ } else {
247
+ // Trailing whitespace of non-last-child - add content indent
248
+ result.push(contentIndent);
249
+ }
250
+ } else {
251
+ // Line has content - add proper indent
252
+ result.push(contentIndent + content);
253
+ }
254
+ }
255
+
256
+ const normalizedValueSource = result.join('\n');
257
+ if (normalizedValueSource === literal.valueSource) {
258
+ return literal;
259
+ }
260
+ return produce(literal, draft => {
261
+ draft.valueSource = normalizedValueSource;
129
262
  });
130
263
  }
131
264
 
132
- private normalizeJsxTagEnd(tag: JSX.Tag, myIndent: string): JSX.Tag {
133
- if (!tag.children || tag.children.length === 0) {
134
- return tag;
265
+ private isLastChildOfJsxTag(literal: J.Literal): boolean {
266
+ const parentCursor = this.cursor.parentTree();
267
+ if (parentCursor && parentCursor.value.kind === JS.Kind.JsxTag) {
268
+ const tag = parentCursor.value as JSX.Tag;
269
+ if (tag.children && tag.children.length > 0) {
270
+ const lastChild = tag.children[tag.children.length - 1];
271
+ // Compare by id since object references might differ after transformations
272
+ return lastChild.kind === J.Kind.Literal && (lastChild as J.Literal).id === literal.id;
273
+ }
135
274
  }
136
- const lastChild = tag.children[tag.children.length - 1];
137
- if (lastChild.kind !== J.Kind.Literal || !lastChild.prefix.whitespace.includes("\n")) {
138
- return tag;
275
+ return false;
276
+ }
277
+
278
+ private normalizeBlockEnd(block: J.Block, myIndent: string): J.Block {
279
+ const effectiveLastWs = lastWhitespace(block.end);
280
+ if (!effectiveLastWs.includes("\n")) {
281
+ return block;
139
282
  }
140
- return produce(tag, draft => {
141
- const lastChildDraft = draft.children![draft.children!.length - 1];
142
- lastChildDraft.prefix.whitespace = this.combineIndent(lastChildDraft.prefix.whitespace, myIndent);
283
+ return produce(block, draft => {
284
+ draft.end = replaceLastWhitespace(draft.end, ws => replaceIndentAfterLastNewline(ws, myIndent));
143
285
  });
144
286
  }
145
287
 
146
288
  public async visitContainer<T extends J>(container: J.Container<T>, p: P): Promise<J.Container<T>> {
147
- const parentIndent = this.cursor.messages.get("myIndent") as string ?? "";
148
- const elementsIndent = container.before.whitespace.includes("\n")
149
- ? parentIndent + this.singleIndent
150
- : parentIndent;
289
+ // Create cursor for this container
290
+ this.cursor = new Cursor(container, this.cursor);
151
291
 
152
- const savedMyIndent = this.cursor.messages.get("myIndent");
153
- this.cursor.messages.set("myIndent", elementsIndent);
154
- let ret = await super.visitContainer(container, p);
155
- if (savedMyIndent !== undefined) {
156
- this.cursor.messages.set("myIndent", savedMyIndent);
157
- }
292
+ // Pre-visit hook: set up cursor messages
293
+ this.preVisitContainer(container);
158
294
 
159
- if (ret.before.whitespace.includes("\n")) {
160
- ret = produce(ret, draft => {
161
- draft.before.whitespace = this.combineIndent(draft.before.whitespace, elementsIndent);
162
- });
163
- }
295
+ // Visit children (similar to base visitor but without cursor management)
296
+ let ret = (await produceAsync<J.Container<T>>(container, async draft => {
297
+ draft.before = await this.visitSpace(container.before, p);
298
+ (draft.elements as J.RightPadded<J>[]) = await mapAsync(container.elements, e => this.visitRightPadded(e, p));
299
+ draft.markers = await this.visitMarkers(container.markers, p);
300
+ }))!;
164
301
 
165
- if (ret.elements.length > 0) {
166
- const effectiveLastWs = lastWhitespace(ret.elements[ret.elements.length - 1].after);
302
+ // Post-visit hook: normalize indentation
303
+ ret = this.postVisitContainer(ret);
304
+
305
+ // Restore cursor
306
+ this.cursor = this.cursor.parent!;
307
+
308
+ return ret;
309
+ }
310
+
311
+ private preVisitContainer<T extends J>(container: J.Container<T>): void {
312
+ const myIndent = this.cursor.parent?.messages.get("myIndent") as string ?? "";
313
+ this.cursor.messages.set("myIndent", myIndent);
314
+ this.cursor.messages.set("indentKind", 'continuation');
315
+ }
316
+
317
+ private postVisitContainer<T extends J>(container: J.Container<T>): J.Container<T> {
318
+ const parentIndent = this.cursor.parent?.messages.get("myIndent") as string ?? "";
319
+
320
+ // Normalize the last element's after whitespace (closing delimiter like `)`)
321
+ // The closing delimiter should align with the parent's indent level
322
+ if (container.elements.length > 0) {
323
+ const effectiveLastWs = lastWhitespace(container.elements[container.elements.length - 1].after);
167
324
  if (effectiveLastWs.includes("\n")) {
168
- ret = produce(ret, draft => {
325
+ return produce(container, draft => {
169
326
  const lastDraft = draft.elements[draft.elements.length - 1];
170
- lastDraft.after = replaceLastWhitespace(lastDraft.after, ws => this.combineIndent(ws, parentIndent));
327
+ lastDraft.after = replaceLastWhitespace(lastDraft.after, ws => replaceIndentAfterLastNewline(ws, parentIndent));
171
328
  });
172
329
  }
173
330
  }
174
331
 
175
- return ret;
332
+ return container;
176
333
  }
177
334
 
178
335
  public async visitLeftPadded<T extends J | J.Space | number | string | boolean>(
179
336
  left: J.LeftPadded<T>,
180
337
  p: P
181
338
  ): 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';
339
+ // Create cursor for this LeftPadded
340
+ this.cursor = new Cursor(left, this.cursor);
341
+
342
+ // Pre-visit hook: set up cursor messages
343
+ this.preVisitLeftPadded(left);
344
+
345
+ // Visit children (similar to base visitor but without cursor management)
346
+ let ret = await produceAsync<J.LeftPadded<T>>(left, async draft => {
347
+ draft.before = await this.visitSpace(left.before, p);
348
+ if (isTree(left.element)) {
349
+ (draft.element as J) = await this.visitDefined(left.element, p);
350
+ } else if (isSpace(left.element)) {
351
+ (draft.element as J.Space) = await this.visitSpace(left.element, p);
352
+ }
353
+ draft.markers = await this.visitMarkers(left.markers, p);
354
+ });
355
+
356
+ // Post-visit hook: normalize indentation
357
+ ret = this.postVisitLeftPadded(ret);
358
+
359
+ // Restore cursor
360
+ this.cursor = this.cursor.parent!;
361
+
362
+ return ret;
363
+ }
364
+
365
+ private preVisitLeftPadded<T extends J | J.Space | number | string | boolean>(left: J.LeftPadded<T>): void {
366
+ // Get parent indent from parent cursor
367
+ const parentIndent = this.cursor.parent?.messages.get("myIndent") as string ?? "";
184
368
  const hasNewline = left.before.whitespace.includes("\n");
185
- const shouldPropagate = hasNewline && continuation === 'propagate';
186
369
 
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);
370
+ // Check if parent is a Binary in align mode - if so, don't add continuation indent
371
+ // (Binary sets indentKind='align' when it's already on a continuation line)
372
+ const parentValue = this.cursor.parent?.value;
373
+ const parentIndentKind = this.cursor.parent?.messages.get("indentKind");
374
+ const shouldAlign = parentValue?.kind === J.Kind.Binary && parentIndentKind === 'align';
375
+
376
+ // Compute myIndent INCLUDING continuation if applicable
377
+ // This ensures child elements see the correct parent indent
378
+ let myIndent = parentIndent;
379
+ if (hasNewline && !shouldAlign) {
380
+ myIndent = parentIndent + this.singleIndent;
191
381
  }
192
382
 
193
- const ret = await super.visitLeftPadded(left, p);
383
+ this.cursor.messages.set("myIndent", myIndent);
384
+ this.cursor.messages.set("hasNewline", hasNewline);
385
+ }
194
386
 
195
- // Restore myIndent
196
- if (savedMyIndent !== undefined) {
197
- this.cursor.messages.set("myIndent", savedMyIndent);
198
- } else if (shouldPropagate) {
199
- this.cursor.messages.delete("myIndent");
387
+ private postVisitLeftPadded<T extends J | J.Space | number | string | boolean>(
388
+ left: J.LeftPadded<T> | undefined
389
+ ): J.LeftPadded<T> | undefined {
390
+ if (left === undefined) {
391
+ return undefined;
200
392
  }
201
393
 
202
- if (ret === undefined || !hasNewline) {
203
- return ret;
394
+ const hasNewline = this.cursor.messages.get("hasNewline") as boolean ?? false;
395
+ if (!hasNewline) {
396
+ return left;
204
397
  }
205
- return produce(ret, draft => {
206
- draft.before.whitespace = this.combineIndent(draft.before.whitespace, parentIndent + this.singleIndent);
398
+
399
+ // Use the myIndent we computed in preVisitLeftPadded (which includes continuation if applicable)
400
+ const targetIndent = this.cursor.messages.get("myIndent") as string ?? "";
401
+ return produce(left, draft => {
402
+ draft.before.whitespace = replaceIndentAfterLastNewline(draft.before.whitespace, targetIndent);
207
403
  });
208
404
  }
209
405
 
406
+ public async visitRightPadded<T extends J | boolean>(
407
+ right: J.RightPadded<T>,
408
+ p: P
409
+ ): Promise<J.RightPadded<T> | undefined> {
410
+ // Create cursor for this RightPadded
411
+ this.cursor = new Cursor(right, this.cursor);
412
+
413
+ // Pre-visit hook: set up cursor messages, propagate continuation if after has newline
414
+ this.preVisitRightPadded(right);
415
+
416
+ // Visit children (similar to base visitor but without cursor management)
417
+ let ret = await produceAsync<J.RightPadded<T>>(right, async draft => {
418
+ if (isTree(right.element)) {
419
+ (draft.element as J) = await this.visitDefined(right.element, p);
420
+ }
421
+ draft.after = await this.visitSpace(right.after, p);
422
+ draft.markers = await this.visitMarkers(right.markers, p);
423
+ });
424
+
425
+ // Restore cursor
426
+ this.cursor = this.cursor.parent!;
427
+
428
+ if (ret?.element === undefined) {
429
+ return undefined;
430
+ }
431
+ return ret;
432
+ }
433
+
434
+ private preVisitRightPadded<T extends J | boolean>(right: J.RightPadded<T>): void {
435
+ // Get parent indent from parent cursor
436
+ const parentIndent = this.cursor.parent?.messages.get("myIndent") as string ?? "";
437
+ const parentIndentKind = this.cursor.parent?.messages.get("indentKind") as string ?? "";
438
+
439
+ // Check if the `after` has a newline (e.g., in method chains like `db\n .from()`)
440
+ const hasNewline = right.after.whitespace.includes("\n");
441
+
442
+ // Only apply chainedIndent logic for method chains (when parent is a MethodInvocation)
443
+ const parentKind = this.cursor.parent?.value?.kind;
444
+ const isMethodChain = parentKind === J.Kind.MethodInvocation;
445
+
446
+ let myIndent = parentIndent;
447
+ if (hasNewline && isMethodChain) {
448
+ // Search up the cursor hierarchy for an existing chainedIndent (to avoid stacking in method chains)
449
+ const existingChainedIndent = this.cursor.parent?.messages.get("chainedIndent") as string | undefined;
450
+ if (existingChainedIndent === undefined) {
451
+ myIndent = parentIndent + this.singleIndent;
452
+ // Set myIndent on parent so Container siblings and further chain elements use it
453
+ this.cursor.parent?.messages.set("myIndent", myIndent);
454
+ this.cursor.parent?.messages.delete("chainedIndent");
455
+ } else {
456
+ myIndent = existingChainedIndent;
457
+ }
458
+ } else if (parentIndentKind !== 'align' && isJava(right.element) && this.elementPrefixContainsNewline(right.element as J)) {
459
+ myIndent = parentIndent + this.singleIndent;
460
+ // For spread elements with newlines, mark continuation as established
461
+ // This allows subsequent elements on the SAME line to inherit the continuation level
462
+ const element = right.element as J;
463
+ if (this.isSpreadElement(element)) {
464
+ this.cursor.parent?.messages.set("continuationIndent", myIndent);
465
+ }
466
+ } else if (isJava(right.element) && !this.elementPrefixContainsNewline(right.element as J)) {
467
+ // Element has no newline - check if a previous sibling established continuation
468
+ const continuationIndent = this.cursor.parent?.messages.get("continuationIndent") as string | undefined;
469
+ if (continuationIndent !== undefined) {
470
+ myIndent = continuationIndent;
471
+ }
472
+ }
473
+
474
+ this.cursor.messages.set("myIndent", myIndent);
475
+ this.cursor.messages.set("indentKind", "align");
476
+ }
477
+
478
+ private elementPrefixContainsNewline(element: J): boolean {
479
+ // Check the element's own prefix
480
+ if (lastWhitespace(element.prefix).includes("\n")) {
481
+ return true;
482
+ }
483
+ // Also check for Spread marker's prefix
484
+ const spreadMarker = element.markers?.markers?.find(m => m.kind === JS.Markers.Spread) as { prefix: J.Space } | undefined;
485
+ if (spreadMarker && spaceContainsNewline(spreadMarker.prefix)) {
486
+ return true;
487
+ }
488
+ return false;
489
+ }
490
+
491
+ /**
492
+ * Check if an element is a spread - either directly marked with Spread marker,
493
+ * or a PropertyAssignment whose name has a Spread marker (for object spreads).
494
+ */
495
+ private isSpreadElement(element: J): boolean {
496
+ // Direct spread marker on element
497
+ if (findMarker(element, JS.Markers.Spread)) {
498
+ return true;
499
+ }
500
+ // PropertyAssignment wrapping a spread (for object spread like `...obj`)
501
+ if (element.kind === JS.Kind.PropertyAssignment) {
502
+ const propAssign = element as JS.PropertyAssignment;
503
+ const nameElement = propAssign.name?.element;
504
+ if (findMarker(nameElement, JS.Markers.Spread)) {
505
+ return true;
506
+ }
507
+ }
508
+ return false;
509
+ }
510
+
210
511
  async visit<R extends J>(tree: Tree, p: P, parent?: Cursor): Promise<R | undefined> {
211
512
  if (this.cursor?.getNearestMessage("stop") != null) {
212
513
  return tree as R;
@@ -267,14 +568,9 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
267
568
  }
268
569
 
269
570
  private isActualJNode(v: any): v is J {
270
- return (isJava(v) || isJavaScript(v)) &&
571
+ return isJava(v) &&
271
572
  v.kind !== J.Kind.Container &&
272
573
  v.kind !== J.Kind.LeftPadded &&
273
574
  v.kind !== J.Kind.RightPadded;
274
575
  }
275
-
276
- private combineIndent(oldWs: string, newIndent: string): string {
277
- const lastNewline = oldWs.lastIndexOf("\n");
278
- return oldWs.substring(0, lastNewline + 1) + newIndent;
279
- }
280
576
  }
package/src/run.ts CHANGED
@@ -78,13 +78,14 @@ export type ProgressCallback = (phase: 'parsing' | 'scanning' | 'processing', cu
78
78
  * Streaming version of scheduleRun that yields results as soon as each file is processed.
79
79
  * This allows callers to print diffs immediately and free memory earlier.
80
80
  *
81
- * Accepts either an array or an async iterable of source files. When the recipe is not
82
- * a scanning recipe, files are processed immediately as they're yielded from the iterable,
83
- * avoiding the need to collect all files into memory first.
81
+ * Accepts either an array or an async iterable of source files. Files are processed
82
+ * immediately as they're yielded from the iterable, avoiding the need to collect all
83
+ * files into memory before starting work.
84
84
  *
85
- * For scanning recipes, the scan phase completes on all files before yielding any results.
85
+ * For scanning recipes, each file is scanned immediately as it's pulled from the generator,
86
+ * then stored for the edit phase. The scan phase completes before any results are yielded.
86
87
  *
87
- * @param onProgress Optional callback for progress updates during parsing, scanning, and processing phases.
88
+ * @param onProgress Optional callback for progress updates during scanning and processing phases.
88
89
  */
89
90
  export async function* scheduleRunStreaming(
90
91
  recipe: Recipe,
@@ -96,23 +97,20 @@ export async function* scheduleRunStreaming(
96
97
  const isScanning = await hasScanningRecipe(recipe);
97
98
 
98
99
  if (isScanning) {
99
- // For scanning recipes, we need to collect all files first for the scan phase
100
- const files: SourceFile[] = Array.isArray(before) ? [...before] : [];
101
- if (!Array.isArray(before)) {
102
- let parseCount = 0;
103
- for await (const sf of before) {
104
- files.push(sf);
105
- parseCount++;
106
- onProgress?.('parsing', parseCount, -1, sf.sourcePath); // -1 = unknown total
107
- }
108
- }
100
+ // For scanning recipes, pull files from the generator and scan them immediately.
101
+ // Files are stored for the later edit phase.
102
+ const files: SourceFile[] = [];
103
+ const iterable = Array.isArray(before) ? before : before;
104
+ const knownTotal = Array.isArray(before) ? before.length : -1; // -1 = unknown total
109
105
 
110
- const totalFiles = files.length;
106
+ // Phase 1: Pull files from generator and scan each immediately
107
+ let scanCount = 0;
108
+ for await (const b of iterable) {
109
+ files.push(b);
110
+ scanCount++;
111
+ onProgress?.('scanning', scanCount, knownTotal, b.sourcePath);
111
112
 
112
- // Phase 1: Run scanners on all files
113
- for (let i = 0; i < files.length; i++) {
114
- const b = files[i];
115
- onProgress?.('scanning', i + 1, totalFiles, b.sourcePath);
113
+ // Scan this file immediately
116
114
  await recurseRecipeList(recipe, b, async (recipe, b2) => {
117
115
  if (recipe instanceof ScanningRecipe) {
118
116
  return (await recipe.scanner(recipe.accumulator(cursor, ctx))).visit(b2, ctx, cursor)
@@ -120,6 +118,8 @@ export async function* scheduleRunStreaming(
120
118
  });
121
119
  }
122
120
 
121
+ const totalFiles = files.length;
122
+
123
123
  // Phase 2: Collect generated files
124
124
  const generated = (await recurseRecipeList(recipe, [] as SourceFile[], async (recipe, generated) => {
125
125
  if (recipe instanceof ScanningRecipe) {
@@ -279,9 +279,9 @@ export function dedent(s: string): string {
279
279
  const str = start > 0 || end < s.length ? s.slice(start, end) : s;
280
280
  const lines = str.split('\n');
281
281
 
282
- // If we removed a leading newline, consider all lines for minIndent
283
- // Otherwise, skip the first line (it's on the same line as the opening quote)
284
- const startLine = start > 0 ? 0 : 1;
282
+ // Always consider all lines for minIndent calculation
283
+ // If first line has content at column 0, minIndent will be 0 and no dedenting happens
284
+ const startLine = 0;
285
285
 
286
286
  // Find minimum indentation
287
287
  let minIndent = Infinity;
@@ -305,9 +305,9 @@ export function dedent(s: string): string {
305
305
  return lines.join('\n');
306
306
  }
307
307
 
308
- // Remove common indentation from lines (skip first line only if we didn't remove leading newline)
309
- return lines.map((line, i) =>
310
- (i === 0 && startLine === 1) ? line : (line.length >= minIndent ? line.slice(minIndent) : '')
308
+ // Remove common indentation from all lines
309
+ return lines.map(line =>
310
+ line.length >= minIndent ? line.slice(minIndent) : ''
311
311
  ).join('\n');
312
312
  }
313
313