Skip to content

Commit de857e4

Browse files
JavaScript: Various formatter fixes (#6382)
- Extract `TabsAndIndentsVisitor` into dedicated file with cleaner architecture - Fix indentation for control structures with non-block bodies (if/while/for without braces) - Fix async generator method spacing (`async* parse()` instead of `async *parse()`) - Add `lastWhitespace()` and `replaceLastWhitespace()` utilities for proper comment handling - Preserve newlines in binary expressions, ternary operators, and multi-line method arguments
1 parent d531e22 commit de857e4

11 files changed

Lines changed: 1244 additions & 477 deletions

File tree

rewrite-javascript/rewrite/src/java/formatting-utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,37 @@
1717
import {J} from "../java";
1818
import {produce} from "immer";
1919

20+
/**
21+
* Gets the effective last whitespace from a Space.
22+
* When there are comments, the last whitespace is the suffix of the last comment.
23+
* When there are no comments, it's the whitespace property.
24+
*/
25+
export function lastWhitespace(space: J.Space): string {
26+
if (space.comments.length > 0) {
27+
return space.comments[space.comments.length - 1].suffix;
28+
}
29+
return space.whitespace;
30+
}
31+
32+
/**
33+
* Replaces the effective last whitespace in a Space using a transform function.
34+
* When there are comments, updates the suffix of the last comment.
35+
* When there are no comments, updates the whitespace property.
36+
*
37+
* @param space The Space to modify (Immer draft)
38+
* @param transform Function that receives the current last whitespace and returns the new value
39+
*/
40+
export function replaceLastWhitespace(space: J.Space, transform: (ws: string) => string): J.Space {
41+
return produce(space, draft => {
42+
if (draft.comments.length > 0) {
43+
const lastComment = draft.comments[draft.comments.length - 1];
44+
lastComment.suffix = transform(lastComment.suffix);
45+
} else {
46+
draft.whitespace = transform(draft.whitespace);
47+
}
48+
});
49+
}
50+
2051
/**
2152
* Handles element removal from lists while preserving LST formatting.
2253
* Automatically applies prefixes from removed elements to the next kept element,

rewrite-javascript/rewrite/src/java/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ export * from "./tree";
1818
export * from "./markers";
1919
export * from "./visitor";
2020
export * from "./type-visitor";
21+
export * from "./formatting-utils";
2122

2223
import "./print";

rewrite-javascript/rewrite/src/javascript/format.ts

Lines changed: 63 additions & 194 deletions
Large diffs are not rendered by default.

rewrite-javascript/rewrite/src/javascript/parser.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3431,6 +3431,8 @@ export class JavaScriptParserVisitor {
34313431
}
34323432

34333433
visitCaseBlock(node: ts.CaseBlock): J.Block {
3434+
// consume end space so it gets assigned to the block's `end`
3435+
const end = this.prefix(node.getLastToken()!);
34343436
return {
34353437
kind: J.Kind.Block,
34363438
id: randomId(),
@@ -3442,7 +3444,7 @@ export class JavaScriptParserVisitor {
34423444
this.visit(clause),
34433445
this.suffix(clause)
34443446
)),
3445-
end: this.prefix(node.getLastToken()!)
3447+
end: end
34463448
}
34473449
}
34483450

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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 {isJavaScript, JS, JSX} from "./tree";
17+
import {JavaScriptVisitor} from "./visitor";
18+
import {isJava, J, lastWhitespace, replaceLastWhitespace} from "../java";
19+
import {produce} from "immer";
20+
import {Cursor, isScope, Tree} from "../tree";
21+
import {TabsAndIndentsStyle} from "./style";
22+
23+
type IndentKind = 'block' | 'continuation' | 'align';
24+
export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
25+
private readonly singleIndent: string;
26+
27+
constructor(private readonly tabsAndIndentsStyle: TabsAndIndentsStyle, private stopAfter?: Tree) {
28+
super();
29+
30+
if (this.tabsAndIndentsStyle.useTabCharacter) {
31+
this.singleIndent = "\t";
32+
} else {
33+
this.singleIndent = " ".repeat(this.tabsAndIndentsStyle.indentSize);
34+
}
35+
}
36+
37+
protected async preVisit(tree: J, _p: P): Promise<J | undefined> {
38+
this.setupCursorMessagesForTree(this.cursor, tree);
39+
return tree;
40+
}
41+
42+
private setupCursorMessagesForTree(cursor: Cursor, tree: J): void {
43+
const [parentMyIndent, parentIndentKind] = this.getParentIndentContext(cursor);
44+
const myIndent = this.computeMyIndent(tree, parentMyIndent, parentIndentKind);
45+
cursor.messages.set("myIndent", myIndent);
46+
cursor.messages.set("indentKind", this.computeIndentKind(tree));
47+
}
48+
49+
private getParentIndentContext(cursor: Cursor): [string, IndentKind] {
50+
for (let c = cursor.parent; c != null; c = c.parent) {
51+
const indent = c.messages.get("myIndent") as string | undefined;
52+
if (indent !== undefined) {
53+
const kind = c.messages.get("indentKind") as IndentKind ?? 'continuation';
54+
return [indent, kind];
55+
}
56+
}
57+
return ["", 'continuation'];
58+
}
59+
60+
private computeMyIndent(tree: J, parentMyIndent: string, parentIndentKind: IndentKind): string {
61+
if (tree.kind === J.Kind.IfElse || parentIndentKind === 'align') {
62+
return parentMyIndent;
63+
}
64+
if (parentIndentKind === 'block') {
65+
return parentMyIndent + this.singleIndent;
66+
}
67+
const hasNewline = tree.prefix?.whitespace?.includes("\n") ||
68+
tree.prefix?.comments?.some(c => c.suffix.includes("\n"));
69+
return hasNewline ? parentMyIndent + this.singleIndent : parentMyIndent;
70+
}
71+
72+
private computeIndentKind(tree: J): IndentKind {
73+
switch (tree.kind) {
74+
case J.Kind.Block:
75+
case J.Kind.Case:
76+
return 'block';
77+
case JS.Kind.CompilationUnit:
78+
return 'align';
79+
default:
80+
return 'continuation';
81+
}
82+
}
83+
84+
override async postVisit(tree: J, _p: P): Promise<J | undefined> {
85+
if (this.stopAfter != null && isScope(this.stopAfter, tree)) {
86+
this.cursor?.root.messages.set("stop", true);
87+
}
88+
89+
const myIndent = this.cursor.messages.get("myIndent") as string | undefined;
90+
if (myIndent === undefined) {
91+
return tree;
92+
}
93+
94+
let result = tree;
95+
if (result.prefix?.whitespace?.includes("\n")) {
96+
result = produce(result, draft => {
97+
draft.prefix!.whitespace = this.combineIndent(draft.prefix!.whitespace, myIndent);
98+
});
99+
}
100+
101+
if (result.kind === J.Kind.Block) {
102+
result = this.normalizeBlockEnd(result as J.Block, myIndent);
103+
} else if (result.kind === JS.Kind.JsxTag) {
104+
result = this.normalizeJsxTagEnd(result as JSX.Tag, myIndent);
105+
}
106+
107+
return result;
108+
}
109+
110+
private normalizeBlockEnd(block: J.Block, myIndent: string): J.Block {
111+
const effectiveLastWs = lastWhitespace(block.end);
112+
if (!effectiveLastWs.includes("\n")) {
113+
return block;
114+
}
115+
return produce(block, draft => {
116+
draft.end = replaceLastWhitespace(draft.end, ws => this.combineIndent(ws, myIndent));
117+
});
118+
}
119+
120+
private normalizeJsxTagEnd(tag: JSX.Tag, myIndent: string): JSX.Tag {
121+
if (!tag.children || tag.children.length === 0) {
122+
return tag;
123+
}
124+
const lastChild = tag.children[tag.children.length - 1];
125+
if (lastChild.kind !== J.Kind.Literal || !lastChild.prefix.whitespace.includes("\n")) {
126+
return tag;
127+
}
128+
return produce(tag, draft => {
129+
const lastChildDraft = draft.children![draft.children!.length - 1];
130+
lastChildDraft.prefix.whitespace = this.combineIndent(lastChildDraft.prefix.whitespace, myIndent);
131+
});
132+
}
133+
134+
public async visitContainer<T extends J>(container: J.Container<T>, p: P): Promise<J.Container<T>> {
135+
const parentIndent = this.cursor.messages.get("myIndent") as string ?? "";
136+
const elementsIndent = container.before.whitespace.includes("\n")
137+
? parentIndent + this.singleIndent
138+
: parentIndent;
139+
140+
const savedMyIndent = this.cursor.messages.get("myIndent");
141+
this.cursor.messages.set("myIndent", elementsIndent);
142+
let ret = await super.visitContainer(container, p);
143+
if (savedMyIndent !== undefined) {
144+
this.cursor.messages.set("myIndent", savedMyIndent);
145+
}
146+
147+
if (ret.before.whitespace.includes("\n")) {
148+
ret = produce(ret, draft => {
149+
draft.before.whitespace = this.combineIndent(draft.before.whitespace, elementsIndent);
150+
});
151+
}
152+
153+
if (ret.elements.length > 0) {
154+
const effectiveLastWs = lastWhitespace(ret.elements[ret.elements.length - 1].after);
155+
if (effectiveLastWs.includes("\n")) {
156+
ret = produce(ret, draft => {
157+
const lastDraft = draft.elements[draft.elements.length - 1];
158+
lastDraft.after = replaceLastWhitespace(lastDraft.after, ws => this.combineIndent(ws, parentIndent));
159+
});
160+
}
161+
}
162+
163+
return ret;
164+
}
165+
166+
public async visitLeftPadded<T extends J | J.Space | number | string | boolean>(
167+
left: J.LeftPadded<T>,
168+
p: P
169+
): Promise<J.LeftPadded<T> | undefined> {
170+
const ret = await super.visitLeftPadded(left, p);
171+
if (ret === undefined || !ret.before.whitespace.includes("\n")) {
172+
return ret;
173+
}
174+
const parentIndent = this.cursor.messages.get("myIndent") as string ?? "";
175+
return produce(ret, draft => {
176+
draft.before.whitespace = this.combineIndent(draft.before.whitespace, parentIndent + this.singleIndent);
177+
});
178+
}
179+
180+
async visit<R extends J>(tree: Tree, p: P, parent?: Cursor): Promise<R | undefined> {
181+
if (this.cursor?.getNearestMessage("stop") != null) {
182+
return tree as R;
183+
}
184+
185+
if (parent) {
186+
this.cursor = new Cursor(tree, parent);
187+
this.setupAncestorIndents();
188+
}
189+
190+
return await super.visit(tree, p) as R;
191+
}
192+
193+
private setupAncestorIndents(): void {
194+
const path: Cursor[] = [];
195+
let anchorCursor: Cursor | undefined;
196+
let anchorIndent = "";
197+
198+
for (let c = this.cursor.parent; c; c = c.parent) {
199+
path.push(c);
200+
const v = c.value;
201+
202+
if (this.isActualJNode(v) && !anchorCursor && v.prefix) {
203+
const ws = lastWhitespace(v.prefix);
204+
const idx = ws.lastIndexOf('\n');
205+
if (idx !== -1) {
206+
anchorCursor = c;
207+
anchorIndent = ws.substring(idx + 1);
208+
}
209+
}
210+
211+
if (v.kind === JS.Kind.CompilationUnit) {
212+
if (!anchorCursor) {
213+
anchorCursor = c;
214+
anchorIndent = "";
215+
}
216+
break;
217+
}
218+
}
219+
220+
if (path.length === 0) return;
221+
path.reverse();
222+
223+
for (const c of path) {
224+
const v = c.value;
225+
if (!this.isActualJNode(v)) continue;
226+
227+
const savedCursor = this.cursor;
228+
this.cursor = c;
229+
if (c === anchorCursor) {
230+
c.messages.set("myIndent", anchorIndent);
231+
c.messages.set("indentKind", this.computeIndentKind(v));
232+
} else {
233+
this.setupCursorMessagesForTree(c, v);
234+
}
235+
this.cursor = savedCursor;
236+
}
237+
}
238+
239+
private isActualJNode(v: any): v is J {
240+
return (isJava(v) || isJavaScript(v)) &&
241+
v.kind !== J.Kind.Container &&
242+
v.kind !== J.Kind.LeftPadded &&
243+
v.kind !== J.Kind.RightPadded;
244+
}
245+
246+
private combineIndent(oldWs: string, newIndent: string): string {
247+
const lastNewline = oldWs.lastIndexOf("\n");
248+
return oldWs.substring(0, lastNewline + 1) + newIndent;
249+
}
250+
}

rewrite-javascript/rewrite/test/javascript/add-import.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -734,19 +734,19 @@ describe('AddImport visitor', () => {
734734
await spec.rewriteRun(
735735
typescript(
736736
`
737-
import * as fs from 'fs';
737+
import * as fs from 'fs';
738738
739-
function example() {
740-
placeholder();
741-
}
739+
function example() {
740+
placeholder();
741+
}
742742
`,
743743
`
744-
import * as fs from 'fs';
745-
import {promisify} from 'util';
744+
import * as fs from 'fs';
745+
import {promisify} from 'util';
746746
747-
function example() {
748-
promisify(fs.readFile);
749-
}
747+
function example() {
748+
promisify(fs.readFile);
749+
}
750750
`
751751
)
752752
);

0 commit comments

Comments
 (0)