@openrewrite/rewrite 8.69.0-20251209-122812 → 8.69.0-20251209-152132

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.
@@ -34,17 +34,22 @@ import {TabsAndIndentsStyle} from "./style";
34
34
  import {findMarker} from "../markers";
35
35
 
36
36
  type IndentKind = 'block' | 'continuation' | 'align';
37
+ type IndentContext = [number, IndentKind]; // [indent, kind]
37
38
  export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
38
- private readonly singleIndent: string;
39
+ private readonly indentSize: number;
40
+ private readonly useTabCharacter: boolean;
39
41
 
40
42
  constructor(private readonly tabsAndIndentsStyle: TabsAndIndentsStyle, private stopAfter?: Tree) {
41
43
  super();
44
+ this.indentSize = this.tabsAndIndentsStyle.indentSize;
45
+ this.useTabCharacter = this.tabsAndIndentsStyle.useTabCharacter;
46
+ }
42
47
 
43
- if (this.tabsAndIndentsStyle.useTabCharacter) {
44
- this.singleIndent = "\t";
45
- } else {
46
- this.singleIndent = " ".repeat(this.tabsAndIndentsStyle.indentSize);
48
+ private indentString(indent: number): string {
49
+ if (this.useTabCharacter) {
50
+ return "\t".repeat(Math.floor(indent / this.indentSize));
47
51
  }
52
+ return " ".repeat(indent);
48
53
  }
49
54
 
50
55
  protected async preVisit(tree: J, _p: P): Promise<J | undefined> {
@@ -53,56 +58,90 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
53
58
  }
54
59
 
55
60
  private setupCursorMessagesForTree(cursor: Cursor, tree: J): void {
61
+ // Check if this MethodInvocation starts a method chain (select.after has newline)
62
+ if (tree.kind === J.Kind.MethodInvocation) {
63
+ const mi = tree as J.MethodInvocation;
64
+ if (mi.select && mi.select.after.whitespace.includes("\n")) {
65
+ // This MethodInvocation has a chained method call after it
66
+ // Store the ORIGINAL parent indent context in "chainedIndentContext"
67
+ // This will be propagated down and used when we reach the chain's innermost element
68
+ const parentContext = this.getParentIndentContext(cursor);
69
+ cursor.messages.set("chainedIndentContext", parentContext);
70
+ // For children (arguments), use continuation indent
71
+ // But the prefix will be normalized in postVisit using chainedIndentContext
72
+ const [parentIndent, parentIndentKind] = parentContext;
73
+ cursor.messages.set("indentContext", [parentIndent + this.indentSize, parentIndentKind] as IndentContext);
74
+ return;
75
+ }
76
+ // Check if we're at the base of a chain (no select) and parent has chainedIndentContext
77
+ if (!mi.select) {
78
+ const chainedContext = cursor.parent?.messages.get("chainedIndentContext") as IndentContext | undefined;
79
+ if (chainedContext !== undefined) {
80
+ // Consume the chainedIndentContext - this is the base of the chain
81
+ // The base element gets the original indent (no extra continuation)
82
+ // Propagate chainedIndentContext so the `name` child knows not to add indent
83
+ cursor.messages.set("indentContext", chainedContext);
84
+ cursor.messages.set("chainedIndentContext", chainedContext);
85
+ return;
86
+ }
87
+ }
88
+ }
89
+
90
+ // Check if we're the `name` of a MethodInvocation at the base of a chain
91
+ if (tree.kind === J.Kind.Identifier) {
92
+ const parentValue = cursor.parent?.value;
93
+ if (parentValue?.kind === J.Kind.MethodInvocation) {
94
+ const parentMi = parentValue as J.MethodInvocation;
95
+ // Check if parent has chainedIndentContext (meaning it's at the base of a chain)
96
+ // and we are the `name` (not an argument or something else)
97
+ const parentChainedContext = cursor.parent?.messages.get("chainedIndentContext") as IndentContext | undefined;
98
+ if (parentChainedContext !== undefined && !parentMi.select) {
99
+ // We're the name of a chain-base MethodInvocation - use the chained indent directly
100
+ cursor.messages.set("indentContext", parentChainedContext);
101
+ return;
102
+ }
103
+ }
104
+ }
105
+
56
106
  const [parentMyIndent, parentIndentKind] = this.getParentIndentContext(cursor);
57
107
  const myIndent = this.computeMyIndent(tree, parentMyIndent, parentIndentKind);
58
- cursor.messages.set("myIndent", myIndent);
59
108
 
60
109
  // For Binary, behavior depends on whether it's already on a continuation line
110
+ let indentKind: IndentKind;
61
111
  if (tree.kind === J.Kind.Binary) {
62
- const hasNewline = tree.prefix?.whitespace?.includes("\n") ||
63
- tree.prefix?.comments?.some(c => c.suffix.includes("\n"));
64
112
  // 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');
113
+ const hasNewline = this.prefixContainsNewline(tree);
114
+ indentKind = (hasNewline || parentIndentKind === 'align') ? 'align' : 'continuation';
67
115
  } else {
68
- cursor.messages.set("indentKind", this.computeIndentKind(tree));
116
+ indentKind = this.computeIndentKind(tree);
69
117
  }
70
- }
71
118
 
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;
119
+ cursor.messages.set("indentContext", [myIndent, indentKind] as IndentContext);
120
+ }
77
121
 
122
+ private getParentIndentContext(cursor: Cursor): IndentContext {
123
+ // Walk up the cursor chain to find the nearest indent context
124
+ // We need to walk because intermediate nodes like RightPadded may not have context set
78
125
  for (let c = cursor.parent; c != null; c = c.parent) {
79
- if (parentIndent === undefined) {
80
- const indent = c.messages.get("myIndent") as string | undefined;
81
- if (indent !== undefined) {
82
- parentIndent = indent;
83
- }
126
+ // chainedIndentContext stores the original context - prefer it
127
+ const chainedContext = c.messages.get("chainedIndentContext") as IndentContext | undefined;
128
+ if (chainedContext !== undefined) {
129
+ return chainedContext;
84
130
  }
85
131
 
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;
132
+ const context = c.messages.get("indentContext") as IndentContext | undefined;
133
+ if (context !== undefined) {
134
+ return context;
96
135
  }
97
136
  }
98
137
 
99
- return [parentIndent ?? "", parentKind ?? 'continuation'];
138
+ return [0, 'continuation'];
100
139
  }
101
140
 
102
- private computeMyIndent(tree: J, parentMyIndent: string, parentIndentKind: IndentKind): string {
103
- // CompilationUnit is the root - it always has myIndent="" regardless of prefix content
141
+ private computeMyIndent(tree: J, parentMyIndent: number, parentIndentKind: IndentKind): number {
142
+ // CompilationUnit is the root - it always has myIndent=0 regardless of prefix content
104
143
  if (tree.kind === JS.Kind.CompilationUnit) {
105
- return "";
144
+ return 0;
106
145
  }
107
146
  // TemplateExpressionSpan: reset indent context - template literal content determines its own indentation
108
147
  // The expression inside ${...} should be indented based on where it appears in the template, not outer code
@@ -112,32 +151,38 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
112
151
  const prefix = span.expression?.prefix?.whitespace ?? "";
113
152
  const lastNewline = prefix.lastIndexOf("\n");
114
153
  if (lastNewline >= 0) {
115
- return prefix.slice(lastNewline + 1);
154
+ return prefix.length - lastNewline - 1;
116
155
  }
117
- return "";
156
+ return 0;
118
157
  }
119
158
  if (tree.kind === J.Kind.IfElse || parentIndentKind === 'align') {
120
159
  return parentMyIndent;
121
160
  }
161
+ // Certain structures don't add indent for themselves - they stay at parent level
162
+ // - TryCatch: catch clause is part of try statement
163
+ // - TypeLiteral: { members } in type definitions
164
+ // - EnumValueSet: enum members get same indent as the set itself
165
+ if (tree.kind === J.Kind.TryCatch || tree.kind === JS.Kind.TypeLiteral || tree.kind === J.Kind.EnumValueSet) {
166
+ return parentMyIndent;
167
+ }
122
168
  // Only add indent if this element starts on a new line
123
169
  // Check both the element's prefix and any Spread marker's prefix
124
170
  const hasNewline = this.prefixContainsNewline(tree);
125
171
  if (!hasNewline) {
126
172
  // Special case for JSX: children of JsxTag don't have newlines in their prefix
127
- // (newlines are in text Literal nodes), but nested tags should still get block indent
128
- if (this.isNestedJsxTag(tree)) {
129
- return parentMyIndent + this.singleIndent;
173
+ // (newlines are in text Literal nodes), but JSX children should still get block indent
174
+ if (this.isJsxChildElement(tree)) {
175
+ return parentMyIndent + this.indentSize;
130
176
  }
131
177
  return parentMyIndent;
132
178
  }
133
179
  // Add indent for block children or continuation
134
- return parentMyIndent + this.singleIndent;
180
+ return parentMyIndent + this.indentSize;
135
181
  }
136
182
 
137
183
  private prefixContainsNewline(tree: J): boolean {
138
- // Check the element's own prefix
139
- if (tree.prefix?.whitespace?.includes("\n") ||
140
- tree.prefix?.comments?.some(c => c.suffix.includes("\n"))) {
184
+ // Check if the element starts on a new line (only the last whitespace matters)
185
+ if (tree.prefix && lastWhitespace(tree.prefix).includes("\n")) {
141
186
  return true;
142
187
  }
143
188
  // For elements with Spread marker, check the Spread marker's prefix
@@ -148,9 +193,11 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
148
193
  return false;
149
194
  }
150
195
 
151
- private isNestedJsxTag(tree: J): boolean {
152
- // Check if this is a JsxTag whose parent is also a JsxTag
153
- if (tree.kind !== JS.Kind.JsxTag) {
196
+ private isJsxChildElement(tree: J): boolean {
197
+ // Check if this is a JSX child element whose parent is a JsxTag
198
+ // JSX children (JsxTag, JsxEmbeddedExpression) don't have newlines in their own prefix
199
+ // (newlines are in text Literal nodes), but they should still get block indent
200
+ if (tree.kind !== JS.Kind.JsxTag && tree.kind !== JS.Kind.JsxEmbeddedExpression) {
154
201
  return false;
155
202
  }
156
203
  const parentTree = this.cursor.parentTree();
@@ -162,6 +209,8 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
162
209
  case J.Kind.Block:
163
210
  case J.Kind.Case:
164
211
  case JS.Kind.JsxTag:
212
+ case JS.Kind.TypeLiteral:
213
+ case J.Kind.EnumValueSet:
165
214
  return 'block';
166
215
  case JS.Kind.CompilationUnit:
167
216
  return 'align';
@@ -175,17 +224,40 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
175
224
  this.cursor?.root.messages.set("stop", true);
176
225
  }
177
226
 
178
- const myIndent = this.cursor.messages.get("myIndent") as string | undefined;
179
- if (myIndent === undefined) {
227
+ // If parent has chainedIndentContext but no indentContext yet, we're exiting a chain element
228
+ // Set indentContext on parent = [chainedIndent + indentSize, chainedIndentKind]
229
+ // This only happens once at the innermost element; subsequent parents will already have indentContext
230
+ const parentChainedContext = this.cursor.parent?.messages.get("chainedIndentContext") as IndentContext | undefined;
231
+ const parentHasIndentContext = this.cursor.parent?.messages.has("indentContext");
232
+ if (parentChainedContext !== undefined && !parentHasIndentContext) {
233
+ const [chainedIndent, chainedIndentKind] = parentChainedContext;
234
+ this.cursor.parent!.messages.set("indentContext", [chainedIndent + this.indentSize, chainedIndentKind] as IndentContext);
235
+ }
236
+
237
+ const indentContext = this.cursor.messages.get("indentContext") as IndentContext | undefined;
238
+ if (indentContext === undefined) {
180
239
  return tree;
181
240
  }
241
+ let [myIndent] = indentContext;
242
+
243
+ // For chain-start MethodInvocations, the prefix contains whitespace before the chain BASE
244
+ // Use chainedIndentContext (the original indent) for the prefix, not the continuation indent
245
+ const chainedContext = this.cursor.messages.get("chainedIndentContext") as IndentContext | undefined;
246
+ if (chainedContext !== undefined && tree.kind === J.Kind.MethodInvocation) {
247
+ const mi = tree as J.MethodInvocation;
248
+ if (mi.select && mi.select.after.whitespace.includes("\n")) {
249
+ // This is a chain-start - use original indent for prefix normalization
250
+ myIndent = chainedContext[0];
251
+ }
252
+ }
182
253
 
183
254
  let result = tree;
255
+ const indentStr = this.indentString(myIndent);
184
256
 
185
257
  // Check if the element has a Spread marker - if so, normalize its prefix instead
186
258
  const spreadMarker = result.markers?.markers?.find(m => m.kind === JS.Markers.Spread) as { prefix: J.Space } | undefined;
187
259
  if (spreadMarker && spaceContainsNewline(spreadMarker.prefix)) {
188
- const normalizedPrefix = normalizeSpaceIndent(spreadMarker.prefix, myIndent);
260
+ const normalizedPrefix = normalizeSpaceIndent(spreadMarker.prefix, indentStr);
189
261
  if (normalizedPrefix !== spreadMarker.prefix) {
190
262
  result = produce(result, draft => {
191
263
  const spreadIdx = draft.markers.markers.findIndex(m => m.kind === JS.Markers.Spread);
@@ -196,7 +268,7 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
196
268
  }
197
269
  } else if (result.prefix && spaceContainsNewline(result.prefix)) {
198
270
  // Normalize the entire prefix space including comment suffixes
199
- const normalizedPrefix = normalizeSpaceIndent(result.prefix, myIndent);
271
+ const normalizedPrefix = normalizeSpaceIndent(result.prefix, indentStr);
200
272
  if (normalizedPrefix !== result.prefix) {
201
273
  result = produce(result, draft => {
202
274
  draft.prefix = normalizedPrefix;
@@ -218,21 +290,22 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
218
290
  return parentTree !== undefined && parentTree.value.kind === JS.Kind.JsxTag;
219
291
  }
220
292
 
221
- private normalizeJsxTextContent(literal: J.Literal, myIndent: string): J.Literal {
293
+ private normalizeJsxTextContent(literal: J.Literal, myIndent: number): J.Literal {
222
294
  if (!literal.valueSource || !literal.valueSource.includes("\n")) {
223
295
  return literal;
224
296
  }
225
297
 
226
298
  // Check if this literal is the last child of a JsxTag - if so, its trailing whitespace
227
299
  // should use the parent tag's indent, not the content indent
228
- const parentIndent = this.cursor.parentTree()!.messages.get("myIndent") as string | undefined;
300
+ const parentContext = this.cursor.parentTree()!.messages.get("indentContext") as IndentContext | undefined;
301
+ const parentIndent = parentContext?.[0];
229
302
  const isLastChild = parentIndent !== undefined && this.isLastChildOfJsxTag(literal);
230
303
 
231
304
  // For JSX text content, the newline is in the value, not the prefix.
232
305
  // Since the content IS effectively on a new line, it should get block child indent.
233
306
  // myIndent is the parent's indent (because Literal prefix has no newline),
234
- // so we need to add singleIndent for content lines.
235
- const contentIndent = myIndent + this.singleIndent;
307
+ // so we need to add indentSize for content lines.
308
+ const contentIndent = myIndent + this.indentSize;
236
309
 
237
310
  // Split by newlines and normalize each line's indentation
238
311
  const lines = literal.valueSource.split('\n');
@@ -251,17 +324,17 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
251
324
  // Line has only whitespace (or is empty)
252
325
  if (isLastChild && i === lines.length - 1) {
253
326
  // Trailing whitespace of last child - use parent indent for closing tag alignment
254
- result.push(parentIndent!);
327
+ result.push(this.indentString(parentIndent!));
255
328
  } else if (i < lines.length - 1) {
256
329
  // Empty line in the middle (followed by more lines) - keep empty
257
330
  result.push('');
258
331
  } else {
259
332
  // Trailing whitespace of non-last-child - add content indent
260
- result.push(contentIndent);
333
+ result.push(this.indentString(contentIndent));
261
334
  }
262
335
  } else {
263
336
  // Line has content - add proper indent
264
- result.push(contentIndent + content);
337
+ result.push(this.indentString(contentIndent) + content);
265
338
  }
266
339
  }
267
340
 
@@ -287,13 +360,13 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
287
360
  return false;
288
361
  }
289
362
 
290
- private normalizeBlockEnd(block: J.Block, myIndent: string): J.Block {
363
+ private normalizeBlockEnd(block: J.Block, myIndent: number): J.Block {
291
364
  const effectiveLastWs = lastWhitespace(block.end);
292
365
  if (!effectiveLastWs.includes("\n")) {
293
366
  return block;
294
367
  }
295
368
  return produce(block, draft => {
296
- draft.end = replaceLastWhitespace(draft.end, ws => replaceIndentAfterLastNewline(ws, myIndent));
369
+ draft.end = replaceLastWhitespace(draft.end, ws => replaceIndentAfterLastNewline(ws, this.indentString(myIndent)));
297
370
  });
298
371
  }
299
372
 
@@ -302,7 +375,7 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
302
375
  this.cursor = new Cursor(container, this.cursor);
303
376
 
304
377
  // Pre-visit hook: set up cursor messages
305
- this.preVisitContainer(container);
378
+ this.preVisitContainer();
306
379
 
307
380
  // Visit children (similar to base visitor but without cursor management)
308
381
  let ret = (await produceAsync<J.Container<T>>(container, async draft => {
@@ -320,58 +393,15 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
320
393
  return ret;
321
394
  }
322
395
 
323
- private preVisitContainer<T extends J>(container: J.Container<T>): void {
324
- let myIndent = this.cursor.parent?.messages.get("myIndent") as string ?? "";
325
-
326
- // Check if we're in a method chain - use chainedIndent if available
327
- // This ensures arguments inside method chains like `.select(arg)` inherit the chain's indent level
328
- // BUT stop at scope boundaries:
329
- // 1. Blocks - nested code inside callbacks should NOT use outer chainedIndent
330
- // 2. Other Containers - nested function call arguments should NOT use outer chainedIndent
331
- for (let c = this.cursor.parent; c; c = c.parent) {
332
- // Stop searching if we hit a Block (function body, arrow function body, etc.)
333
- // This prevents chainedIndent from leaking into nested scopes
334
- if (c.value?.kind === J.Kind.Block) {
335
- break;
336
- }
337
- // Stop searching if we hit another Container (arguments of another function call)
338
- // This prevents chainedIndent from leaking into nested function calls
339
- if (c.value?.kind === J.Kind.Container) {
340
- break;
341
- }
342
- const chainedIndent = c.messages.get("chainedIndent") as string | undefined;
343
- if (chainedIndent !== undefined) {
344
- myIndent = chainedIndent;
345
- break;
346
- }
347
- }
348
-
349
- this.cursor.messages.set("myIndent", myIndent);
350
- this.cursor.messages.set("indentKind", 'continuation');
396
+ private preVisitContainer(): void {
397
+ const parentContext = this.cursor.parent?.messages.get("indentContext") as IndentContext | undefined;
398
+ const [myIndent] = parentContext ?? [0, 'continuation'];
399
+ this.cursor.messages.set("indentContext", [myIndent, 'continuation'] as IndentContext);
351
400
  }
352
401
 
353
402
  private postVisitContainer<T extends J>(container: J.Container<T>): J.Container<T> {
354
- let parentIndent = this.cursor.parent?.messages.get("myIndent") as string ?? "";
355
-
356
- // Check for chainedIndent for closing delimiter alignment in method chains
357
- // BUT stop at scope boundaries:
358
- // 1. Blocks - nested code inside callbacks should NOT use outer chainedIndent
359
- // 2. Other Containers - nested function call arguments should NOT use outer chainedIndent
360
- for (let c = this.cursor.parent; c; c = c.parent) {
361
- // Stop searching if we hit a Block (function body, arrow function body, etc.)
362
- if (c.value?.kind === J.Kind.Block) {
363
- break;
364
- }
365
- // Stop searching if we hit another Container (arguments of another function call)
366
- if (c.value?.kind === J.Kind.Container) {
367
- break;
368
- }
369
- const chainedIndent = c.messages.get("chainedIndent") as string | undefined;
370
- if (chainedIndent !== undefined) {
371
- parentIndent = chainedIndent;
372
- break;
373
- }
374
- }
403
+ const parentContext = this.cursor.parent?.messages.get("indentContext") as IndentContext | undefined;
404
+ const [parentIndent] = parentContext ?? [0, 'continuation'];
375
405
 
376
406
  // Normalize the last element's after whitespace (closing delimiter like `)`)
377
407
  // The closing delimiter should align with the parent's indent level
@@ -380,7 +410,7 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
380
410
  if (effectiveLastWs.includes("\n")) {
381
411
  return produce(container, draft => {
382
412
  const lastDraft = draft.elements[draft.elements.length - 1];
383
- lastDraft.after = replaceLastWhitespace(lastDraft.after, ws => replaceIndentAfterLastNewline(ws, parentIndent));
413
+ lastDraft.after = replaceLastWhitespace(lastDraft.after, ws => replaceIndentAfterLastNewline(ws, this.indentString(parentIndent)));
384
414
  });
385
415
  }
386
416
  }
@@ -419,24 +449,21 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
419
449
  }
420
450
 
421
451
  private preVisitLeftPadded<T extends J | J.Space | number | string | boolean>(left: J.LeftPadded<T>): void {
422
- // Get parent indent from parent cursor
423
- const parentIndent = this.cursor.parent?.messages.get("myIndent") as string ?? "";
452
+ const parentContext = this.cursor.parent?.messages.get("indentContext") as IndentContext | undefined;
453
+ const [parentIndent, parentIndentKind] = parentContext ?? [0, 'continuation'];
424
454
  const hasNewline = left.before.whitespace.includes("\n");
425
455
 
426
456
  // Check if parent is a Binary in align mode - if so, don't add continuation indent
427
- // (Binary sets indentKind='align' when it's already on a continuation line)
428
457
  const parentValue = this.cursor.parent?.value;
429
- const parentIndentKind = this.cursor.parent?.messages.get("indentKind");
430
458
  const shouldAlign = parentValue?.kind === J.Kind.Binary && parentIndentKind === 'align';
431
459
 
432
460
  // Compute myIndent INCLUDING continuation if applicable
433
- // This ensures child elements see the correct parent indent
434
461
  let myIndent = parentIndent;
435
462
  if (hasNewline && !shouldAlign) {
436
- myIndent = parentIndent + this.singleIndent;
463
+ myIndent = parentIndent + this.indentSize;
437
464
  }
438
465
 
439
- this.cursor.messages.set("myIndent", myIndent);
466
+ this.cursor.messages.set("indentContext", [myIndent, 'continuation'] as IndentContext);
440
467
  this.cursor.messages.set("hasNewline", hasNewline);
441
468
  }
442
469
 
@@ -452,10 +479,11 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
452
479
  return left;
453
480
  }
454
481
 
455
- // Use the myIndent we computed in preVisitLeftPadded (which includes continuation if applicable)
456
- const targetIndent = this.cursor.messages.get("myIndent") as string ?? "";
482
+ // Use the indent we computed in preVisitLeftPadded (which includes continuation if applicable)
483
+ const context = this.cursor.messages.get("indentContext") as IndentContext | undefined;
484
+ const [targetIndent] = context ?? [0, 'continuation'];
457
485
  return produce(left, draft => {
458
- draft.before.whitespace = replaceIndentAfterLastNewline(draft.before.whitespace, targetIndent);
486
+ draft.before.whitespace = replaceIndentAfterLastNewline(draft.before.whitespace, this.indentString(targetIndent));
459
487
  });
460
488
  }
461
489
 
@@ -488,74 +516,44 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
488
516
  }
489
517
 
490
518
  private preVisitRightPadded<T extends J | boolean>(right: J.RightPadded<T>): void {
491
- // Get parent indent from parent cursor
492
- const parentIndent = this.cursor.parent?.messages.get("myIndent") as string ?? "";
493
- const parentIndentKind = this.cursor.parent?.messages.get("indentKind") as string ?? "";
519
+ const parentContext = this.cursor.parent?.messages.get("indentContext") as IndentContext | undefined;
520
+ const [parentIndent, parentIndentKind] = parentContext ?? [0, 'continuation'];
494
521
 
495
- // Check if the `after` has a newline (e.g., in method chains like `db\n .from()`)
496
- const hasNewline = right.after.whitespace.includes("\n");
522
+ // Check if parent has chainedIndentContext - if so, this is the select of a method chain
523
+ // Propagate chainedIndentContext but do NOT set indentContext
524
+ const parentChainedContext = this.cursor.parent?.messages.get("chainedIndentContext") as IndentContext | undefined;
525
+ if (parentChainedContext !== undefined) {
526
+ this.cursor.messages.set("chainedIndentContext", parentChainedContext);
527
+ // Do NOT set indentContext - child elements will use chainedIndentContext
528
+ return;
529
+ }
497
530
 
498
- // Only apply chainedIndent logic for method chains (when parent is a MethodInvocation)
499
- const parentKind = this.cursor.parent?.value?.kind;
500
- const isMethodChain = parentKind === J.Kind.MethodInvocation;
531
+ // Check if Parentheses wraps a Binary expression - if so, let Binary handle its own indent
532
+ const rightPaddedParentKind = this.cursor.parent?.value?.kind;
533
+ const elementKind = (right.element as any)?.kind;
534
+ const isParenthesesWrappingBinary = rightPaddedParentKind === J.Kind.Parentheses &&
535
+ elementKind === J.Kind.Binary;
501
536
 
502
537
  let myIndent = parentIndent;
503
- if (hasNewline && isMethodChain) {
504
- // Search up the cursor hierarchy for an existing chainedIndent (to avoid stacking in method chains)
505
- let existingChainedIndent: string | undefined;
506
- for (let c = this.cursor.parent; c; c = c.parent) {
507
- existingChainedIndent = c.messages.get("chainedIndent") as string | undefined;
508
- if (existingChainedIndent !== undefined) {
509
- break;
510
- }
511
- }
512
- if (existingChainedIndent === undefined) {
513
- myIndent = parentIndent + this.singleIndent;
514
- // Set chainedIndent on parent so further chain elements don't stack
515
- this.cursor.parent?.messages.set("chainedIndent", myIndent);
516
- } else {
517
- myIndent = existingChainedIndent;
518
- }
519
- } else if (parentIndentKind !== 'align' && isJava(right.element) && this.elementPrefixContainsNewline(right.element as J)) {
520
- myIndent = parentIndent + this.singleIndent;
538
+ if (!isParenthesesWrappingBinary && parentIndentKind !== 'align' && isJava(right.element) && this.prefixContainsNewline(right.element as J)) {
539
+ myIndent = parentIndent + this.indentSize;
521
540
  // For spread elements with newlines, mark continuation as established
522
- // This allows subsequent elements on the SAME line to inherit the continuation level
523
541
  const element = right.element as J;
524
542
  if (this.isSpreadElement(element)) {
525
543
  this.cursor.parent?.messages.set("continuationIndent", myIndent);
526
544
  }
527
- } else if (isJava(right.element) && !this.elementPrefixContainsNewline(right.element as J)) {
545
+ } else if (isJava(right.element) && !this.prefixContainsNewline(right.element as J)) {
528
546
  // Element has no newline - check if a previous sibling established continuation
529
- const continuationIndent = this.cursor.parent?.messages.get("continuationIndent") as string | undefined;
547
+ const continuationIndent = this.cursor.parent?.messages.get("continuationIndent") as number | undefined;
530
548
  if (continuationIndent !== undefined) {
531
549
  myIndent = continuationIndent;
532
550
  }
533
551
  }
534
552
 
535
- this.cursor.messages.set("myIndent", myIndent);
536
553
  // Set 'align' for most RightPadded elements to prevent double-continuation
537
- // EXCEPT when Parentheses wraps a Binary expression - in that case, the Binary's
538
- // children need continuation mode to get proper indentation for multi-line operands
539
- const rightPaddedParentKind = this.cursor.parent?.value?.kind;
540
- const elementKind = (right.element as any)?.kind;
541
- const isParenthesesWrappingBinary = rightPaddedParentKind === J.Kind.Parentheses &&
542
- elementKind === J.Kind.Binary;
543
- if (!isParenthesesWrappingBinary) {
544
- this.cursor.messages.set("indentKind", "align");
545
- }
546
- }
547
-
548
- private elementPrefixContainsNewline(element: J): boolean {
549
- // Check the element's own prefix
550
- if (lastWhitespace(element.prefix).includes("\n")) {
551
- return true;
552
- }
553
- // Also check for Spread marker's prefix
554
- const spreadMarker = element.markers?.markers?.find(m => m.kind === JS.Markers.Spread) as { prefix: J.Space } | undefined;
555
- if (spreadMarker && spaceContainsNewline(spreadMarker.prefix)) {
556
- return true;
557
- }
558
- return false;
554
+ // EXCEPT when Parentheses wraps a Binary expression - use continuation so Binary children align
555
+ const indentKind: IndentKind = isParenthesesWrappingBinary ? 'continuation' : 'align';
556
+ this.cursor.messages.set("indentContext", [myIndent, indentKind] as IndentContext);
559
557
  }
560
558
 
561
559
  /**
@@ -594,7 +592,7 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
594
592
  private setupAncestorIndents(): void {
595
593
  const path: Cursor[] = [];
596
594
  let anchorCursor: Cursor | undefined;
597
- let anchorIndent = "";
595
+ let anchorIndent = 0;
598
596
 
599
597
  for (let c = this.cursor.parent; c; c = c.parent) {
600
598
  path.push(c);
@@ -605,14 +603,14 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
605
603
  const idx = ws.lastIndexOf('\n');
606
604
  if (idx !== -1) {
607
605
  anchorCursor = c;
608
- anchorIndent = ws.substring(idx + 1);
606
+ anchorIndent = ws.length - idx - 1;
609
607
  }
610
608
  }
611
609
 
612
610
  if (v.kind === JS.Kind.CompilationUnit) {
613
611
  if (!anchorCursor) {
614
612
  anchorCursor = c;
615
- anchorIndent = "";
613
+ anchorIndent = 0;
616
614
  }
617
615
  break;
618
616
  }
@@ -628,8 +626,7 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
628
626
  const savedCursor = this.cursor;
629
627
  this.cursor = c;
630
628
  if (c === anchorCursor) {
631
- c.messages.set("myIndent", anchorIndent);
632
- c.messages.set("indentKind", this.computeIndentKind(v));
629
+ c.messages.set("indentContext", [anchorIndent, this.computeIndentKind(v)] as IndentContext);
633
630
  } else {
634
631
  this.setupCursorMessagesForTree(c, v);
635
632
  }