Skip to content

Commit b03a9fc

Browse files
committed
JavaScript: Add simple formatting auto-detection
This is currently rolled into the `auto-format` recipe (using the scanning phase), so that can more easily be tested.
1 parent 2ea8f2b commit b03a9fc

13 files changed

Lines changed: 748 additions & 38 deletions
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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+
17+
import {NamedStyles, Style} from "../style";
18+
import {randomId} from "../uuid";
19+
import {SourceFile} from "../tree";
20+
import {JS} from "./tree";
21+
import {JavaScriptVisitor} from "./visitor";
22+
import {J} from "../java";
23+
import {
24+
IntelliJ,
25+
SpacesStyle,
26+
StyleKind,
27+
TabsAndIndentsStyle,
28+
WrappingAndBracesStyle,
29+
WrappingAndBracesStyleDetailKind
30+
} from "./style";
31+
32+
/**
33+
* Auto-detected styles for JavaScript/TypeScript code.
34+
* Focuses on key formatting variations where projects differ:
35+
* - Tabs vs spaces
36+
* - Indent size (2, 4, etc.)
37+
* - Spaces within ES6 import/export braces
38+
*/
39+
export class Autodetect implements NamedStyles {
40+
readonly kind = "org.openrewrite.marker.NamedStyles" as const;
41+
readonly id: string;
42+
readonly name = "org.openrewrite.javascript.Autodetect";
43+
readonly displayName = "Auto-detected";
44+
readonly description = "Automatically detect styles from a repository's existing code.";
45+
readonly tags: string[] = [];
46+
readonly styles: Style[];
47+
48+
constructor(id: string, styles: Style[]) {
49+
this.id = id;
50+
this.styles = styles;
51+
}
52+
53+
static detector(): Detector {
54+
return new Detector();
55+
}
56+
}
57+
58+
/**
59+
* Collects formatting statistics from source files and builds auto-detected styles.
60+
*/
61+
export class Detector {
62+
private readonly tabsAndIndentsStats = new TabsAndIndentsStatistics();
63+
private readonly spacesStats = new SpacesStatistics();
64+
65+
/**
66+
* Sample a source file to collect formatting statistics.
67+
*/
68+
async sample(sourceFile: SourceFile): Promise<void> {
69+
if (sourceFile.kind === JS.Kind.CompilationUnit) {
70+
await this.sampleJavaScript(sourceFile as JS.CompilationUnit);
71+
}
72+
}
73+
74+
/**
75+
* Sample a JavaScript/TypeScript compilation unit.
76+
*/
77+
async sampleJavaScript(cu: JS.CompilationUnit): Promise<void> {
78+
await new FindIndentVisitor(this.tabsAndIndentsStats).visit(cu, {});
79+
await new FindSpacesVisitor(this.spacesStats).visit(cu, {});
80+
}
81+
82+
/**
83+
* Build the auto-detected styles from collected statistics.
84+
*/
85+
build(): Autodetect {
86+
return new Autodetect(randomId(), [
87+
this.tabsAndIndentsStats.getTabsAndIndentsStyle(),
88+
this.spacesStats.getSpacesStyle(),
89+
this.getWrappingAndBracesStyle(),
90+
]);
91+
}
92+
93+
getTabsAndIndentsStyle(): TabsAndIndentsStyle {
94+
return this.tabsAndIndentsStats.getTabsAndIndentsStyle();
95+
}
96+
97+
getSpacesStyle(): SpacesStyle {
98+
return this.spacesStats.getSpacesStyle();
99+
}
100+
101+
getWrappingAndBracesStyle(): WrappingAndBracesStyle {
102+
return {
103+
kind: StyleKind.WrappingAndBracesStyle,
104+
ifStatement: {
105+
kind: WrappingAndBracesStyleDetailKind.WrappingAndBracesStyleIfStatement,
106+
elseOnNewLine: false
107+
}
108+
};
109+
}
110+
}
111+
112+
// ============================================================================
113+
// Statistics Classes
114+
// ============================================================================
115+
116+
/**
117+
* Tracks indentation patterns to detect tabs vs spaces and indent size.
118+
*/
119+
class TabsAndIndentsStatistics {
120+
private totalSpaceIndents = 0;
121+
private totalTabIndents = 0;
122+
123+
// Track all observed indent sizes to compute GCD
124+
private observedIndents: number[] = [];
125+
126+
recordSpaceIndent(spaceCount: number): void {
127+
this.totalSpaceIndents++;
128+
if (spaceCount > 0) {
129+
this.observedIndents.push(spaceCount);
130+
}
131+
}
132+
133+
recordTabIndent(): void {
134+
this.totalTabIndents++;
135+
}
136+
137+
getTabsAndIndentsStyle(): TabsAndIndentsStyle {
138+
// Determine if using tabs or spaces
139+
const useTabs = this.totalTabIndents > this.totalSpaceIndents;
140+
141+
// Find indent size by computing GCD of all observed indents
142+
// This correctly handles 2-space files where we see 2, 4, 6, 8... (all multiples of 2)
143+
let detectedIndentSize = 4; // Default
144+
if (this.observedIndents.length > 0) {
145+
// Compute GCD of all observed indents
146+
let gcd = this.observedIndents[0];
147+
for (let i = 1; i < this.observedIndents.length; i++) {
148+
gcd = this.computeGcd(gcd, this.observedIndents[i]);
149+
if (gcd === 1) break; // Can't get smaller than 1
150+
}
151+
// Only use common indent sizes (2, 4, 8)
152+
if (gcd === 2 || gcd === 4 || gcd === 8) {
153+
detectedIndentSize = gcd;
154+
} else if (gcd > 0 && gcd % 4 === 0) {
155+
detectedIndentSize = 4;
156+
} else if (gcd > 0 && gcd % 2 === 0) {
157+
detectedIndentSize = 2;
158+
}
159+
}
160+
161+
return {
162+
kind: StyleKind.TabsAndIndentsStyle,
163+
useTabCharacter: useTabs,
164+
tabSize: 4,
165+
indentSize: detectedIndentSize,
166+
continuationIndent: detectedIndentSize * 2,
167+
keepIndentsOnEmptyLines: false,
168+
indentChainedMethods: true,
169+
indentAllChainedCallsInAGroup: false
170+
};
171+
}
172+
173+
private computeGcd(a: number, b: number): number {
174+
while (b !== 0) {
175+
const temp = b;
176+
b = a % b;
177+
a = temp;
178+
}
179+
return a;
180+
}
181+
}
182+
183+
/**
184+
* Tracks spacing patterns around ES6 import/export braces.
185+
*/
186+
class SpacesStatistics {
187+
// Track spaces within ES6 import/export braces: { a } vs {a}
188+
es6ImportExportBracesWithSpace = 0;
189+
es6ImportExportBracesWithoutSpace = 0;
190+
191+
getSpacesStyle(): SpacesStyle {
192+
// Use TypeScript defaults as base since most modern JS/TS projects use similar conventions
193+
// TypeScript defaults include afterTypeReferenceColon: true which is commonly expected
194+
const defaults = IntelliJ.TypeScript.spaces();
195+
196+
return {
197+
...defaults,
198+
within: {
199+
...defaults.within,
200+
es6ImportExportBraces: this.es6ImportExportBracesWithSpace > this.es6ImportExportBracesWithoutSpace
201+
}
202+
};
203+
}
204+
}
205+
206+
// ============================================================================
207+
// Visitor Classes for Collecting Statistics
208+
// ============================================================================
209+
210+
/**
211+
* Detects indentation patterns by examining block contents.
212+
*/
213+
class FindIndentVisitor extends JavaScriptVisitor<any> {
214+
constructor(private stats: TabsAndIndentsStatistics) {
215+
super();
216+
}
217+
218+
protected async visitBlock(block: J.Block, p: any): Promise<J | undefined> {
219+
// Check indentation of statements in the block
220+
for (const stmt of block.statements) {
221+
const whitespace = stmt.element.prefix?.whitespace;
222+
if (whitespace) {
223+
this.analyzeIndent(whitespace);
224+
}
225+
}
226+
return super.visitBlock(block, p);
227+
}
228+
229+
private analyzeIndent(whitespace: string): void {
230+
const newlineIndex = whitespace.lastIndexOf('\n');
231+
if (newlineIndex < 0) return;
232+
233+
const indent = whitespace.substring(newlineIndex + 1);
234+
if (indent.length === 0) return;
235+
236+
// Check first character to determine type
237+
if (indent[0] === '\t') {
238+
this.stats.recordTabIndent();
239+
} else if (indent[0] === ' ') {
240+
// Count consecutive spaces
241+
let spaceCount = 0;
242+
for (const char of indent) {
243+
if (char === ' ') spaceCount++;
244+
else break;
245+
}
246+
if (spaceCount > 0) {
247+
this.stats.recordSpaceIndent(spaceCount);
248+
}
249+
}
250+
}
251+
}
252+
253+
/**
254+
* Detects spacing patterns in imports and exports.
255+
*/
256+
class FindSpacesVisitor extends JavaScriptVisitor<any> {
257+
constructor(private stats: SpacesStatistics) {
258+
super();
259+
}
260+
261+
protected async visitImportDeclaration(import_: JS.Import, p: any): Promise<J | undefined> {
262+
// Check ES6 import braces spacing: import { a } from 'x' vs import {a} from 'x'
263+
if (import_.importClause?.namedBindings?.kind === JS.Kind.NamedImports) {
264+
const namedImports = import_.importClause.namedBindings as JS.NamedImports;
265+
if (namedImports.elements.elements.length > 0) {
266+
const firstElement = namedImports.elements.elements[0];
267+
const hasSpaceAfterOpenBrace = firstElement.element.prefix?.whitespace?.includes(' ') ?? false;
268+
269+
const lastElement = namedImports.elements.elements[namedImports.elements.elements.length - 1];
270+
const hasSpaceBeforeCloseBrace = lastElement.after?.whitespace?.includes(' ') ?? false;
271+
272+
if (hasSpaceAfterOpenBrace || hasSpaceBeforeCloseBrace) {
273+
this.stats.es6ImportExportBracesWithSpace++;
274+
} else {
275+
this.stats.es6ImportExportBracesWithoutSpace++;
276+
}
277+
}
278+
}
279+
return super.visitImportDeclaration(import_, p);
280+
}
281+
282+
protected async visitExportDeclaration(export_: JS.ExportDeclaration, p: any): Promise<J | undefined> {
283+
// Check ES6 export braces spacing
284+
if (export_.exportClause?.kind === JS.Kind.NamedExports) {
285+
const namedExports = export_.exportClause as JS.NamedExports;
286+
if (namedExports.elements.elements.length > 0) {
287+
const firstElement = namedExports.elements.elements[0];
288+
const hasSpaceAfterOpenBrace = firstElement.element.prefix?.whitespace?.includes(' ') ?? false;
289+
290+
const lastElement = namedExports.elements.elements[namedExports.elements.elements.length - 1];
291+
const hasSpaceBeforeCloseBrace = lastElement.after?.whitespace?.includes(' ') ?? false;
292+
293+
if (hasSpaceAfterOpenBrace || hasSpaceBeforeCloseBrace) {
294+
this.stats.es6ImportExportBracesWithSpace++;
295+
} else {
296+
this.stats.es6ImportExportBracesWithoutSpace++;
297+
}
298+
}
299+
}
300+
return super.visitExportDeclaration(export_, p);
301+
}
302+
}

0 commit comments

Comments
 (0)