Skip to content

Commit 99001e5

Browse files
authored
Printer: Implement Indentation Printer (#1300)
This pull request implements a new `IndentPrinter` class in the `@herb-tools/printer` package that can be used to print an AST and preserve all content as-is while replacing leading whitespace on each line with the correct indentation based on the AST nesting depth. Example: ```html <div> <ul> <li> Hello </li> </ul> </div> ``` Becomes: ```html <div> <ul> <li> Hello </li> </ul> </div> ``` This is going to be useful when changing the nodes hierarchy in the AST and want the indentation to be reflected based on that transformation without needing to fully format it. Especially for things like the autofix for the `erb-no-duplicate-branch-elements` linter rule in #1301.
1 parent 13c3b75 commit 99001e5

File tree

3 files changed

+558
-0
lines changed

3 files changed

+558
-0
lines changed
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import { IdentityPrinter } from "./identity-printer.js"
2+
3+
import type * as Nodes from "@herb-tools/core"
4+
5+
/**
6+
* IndentPrinter - Re-indentation printer that preserves content but adjusts indentation
7+
*
8+
* Extends IdentityPrinter to preserve all content as-is while replacing
9+
* leading whitespace on each line with the correct indentation based on
10+
* the AST nesting depth.
11+
*/
12+
export class IndentPrinter extends IdentityPrinter {
13+
protected indentLevel: number = 0
14+
protected indentWidth: number
15+
private pendingIndent: boolean = false
16+
17+
constructor(indentWidth: number = 2) {
18+
super()
19+
20+
this.indentWidth = indentWidth
21+
}
22+
23+
protected get indent(): string {
24+
return " ".repeat(this.indentLevel * this.indentWidth)
25+
}
26+
27+
protected write(content: string): void {
28+
if (this.pendingIndent && content.length > 0) {
29+
this.pendingIndent = false
30+
this.context.write(this.indent + content)
31+
} else {
32+
this.context.write(content)
33+
}
34+
}
35+
36+
visitLiteralNode(node: Nodes.LiteralNode): void {
37+
this.writeWithIndent(node.content)
38+
}
39+
40+
visitHTMLTextNode(node: Nodes.HTMLTextNode): void {
41+
this.writeWithIndent(node.content)
42+
}
43+
44+
visitHTMLElementNode(node: Nodes.HTMLElementNode): void {
45+
const tagName = node.tag_name?.value
46+
47+
if (tagName) {
48+
this.context.enterTag(tagName)
49+
}
50+
51+
if (node.open_tag) {
52+
this.visit(node.open_tag)
53+
}
54+
55+
if (node.body) {
56+
this.indentLevel++
57+
node.body.forEach(child => this.visit(child))
58+
this.indentLevel--
59+
}
60+
61+
if (node.close_tag) {
62+
this.visit(node.close_tag)
63+
}
64+
65+
if (tagName) {
66+
this.context.exitTag()
67+
}
68+
}
69+
70+
visitERBIfNode(node: Nodes.ERBIfNode): void {
71+
this.printERBNode(node)
72+
73+
if (node.statements) {
74+
this.indentLevel++
75+
node.statements.forEach(statement => this.visit(statement))
76+
this.indentLevel--
77+
}
78+
79+
if (node.subsequent) {
80+
this.visit(node.subsequent)
81+
}
82+
83+
if (node.end_node) {
84+
this.visit(node.end_node)
85+
}
86+
}
87+
88+
visitERBElseNode(node: Nodes.ERBElseNode): void {
89+
this.printERBNode(node)
90+
91+
if (node.statements) {
92+
this.indentLevel++
93+
node.statements.forEach(statement => this.visit(statement))
94+
this.indentLevel--
95+
}
96+
}
97+
98+
visitERBBlockNode(node: Nodes.ERBBlockNode): void {
99+
this.printERBNode(node)
100+
101+
if (node.body) {
102+
this.indentLevel++
103+
node.body.forEach(child => this.visit(child))
104+
this.indentLevel--
105+
}
106+
107+
if (node.end_node) {
108+
this.visit(node.end_node)
109+
}
110+
}
111+
112+
visitERBCaseNode(node: Nodes.ERBCaseNode): void {
113+
this.printERBNode(node)
114+
115+
if (node.children) {
116+
this.indentLevel++
117+
node.children.forEach(child => this.visit(child))
118+
this.indentLevel--
119+
}
120+
121+
if (node.conditions) {
122+
this.indentLevel++
123+
node.conditions.forEach(condition => this.visit(condition))
124+
this.indentLevel--
125+
}
126+
127+
if (node.else_clause) {
128+
this.indentLevel++
129+
this.visit(node.else_clause)
130+
this.indentLevel--
131+
}
132+
133+
if (node.end_node) {
134+
this.visit(node.end_node)
135+
}
136+
}
137+
138+
visitERBWhenNode(node: Nodes.ERBWhenNode): void {
139+
this.printERBNode(node)
140+
141+
if (node.statements) {
142+
this.indentLevel++
143+
node.statements.forEach(statement => this.visit(statement))
144+
this.indentLevel--
145+
}
146+
}
147+
148+
visitERBWhileNode(node: Nodes.ERBWhileNode): void {
149+
this.printERBNode(node)
150+
151+
if (node.statements) {
152+
this.indentLevel++
153+
node.statements.forEach(statement => this.visit(statement))
154+
this.indentLevel--
155+
}
156+
157+
if (node.end_node) {
158+
this.visit(node.end_node)
159+
}
160+
}
161+
162+
visitERBUntilNode(node: Nodes.ERBUntilNode): void {
163+
this.printERBNode(node)
164+
165+
if (node.statements) {
166+
this.indentLevel++
167+
node.statements.forEach(statement => this.visit(statement))
168+
this.indentLevel--
169+
}
170+
171+
if (node.end_node) {
172+
this.visit(node.end_node)
173+
}
174+
}
175+
176+
visitERBForNode(node: Nodes.ERBForNode): void {
177+
this.printERBNode(node)
178+
179+
if (node.statements) {
180+
this.indentLevel++
181+
node.statements.forEach(statement => this.visit(statement))
182+
this.indentLevel--
183+
}
184+
185+
if (node.end_node) {
186+
this.visit(node.end_node)
187+
}
188+
}
189+
190+
visitERBBeginNode(node: Nodes.ERBBeginNode): void {
191+
this.printERBNode(node)
192+
193+
if (node.statements) {
194+
this.indentLevel++
195+
node.statements.forEach(statement => this.visit(statement))
196+
this.indentLevel--
197+
}
198+
199+
if (node.rescue_clause) {
200+
this.visit(node.rescue_clause)
201+
}
202+
203+
if (node.else_clause) {
204+
this.visit(node.else_clause)
205+
}
206+
207+
if (node.ensure_clause) {
208+
this.visit(node.ensure_clause)
209+
}
210+
211+
if (node.end_node) {
212+
this.visit(node.end_node)
213+
}
214+
}
215+
216+
visitERBRescueNode(node: Nodes.ERBRescueNode): void {
217+
this.printERBNode(node)
218+
219+
if (node.statements) {
220+
this.indentLevel++
221+
node.statements.forEach(statement => this.visit(statement))
222+
this.indentLevel--
223+
}
224+
225+
if (node.subsequent) {
226+
this.visit(node.subsequent)
227+
}
228+
}
229+
230+
visitERBEnsureNode(node: Nodes.ERBEnsureNode): void {
231+
this.printERBNode(node)
232+
233+
if (node.statements) {
234+
this.indentLevel++
235+
node.statements.forEach(statement => this.visit(statement))
236+
this.indentLevel--
237+
}
238+
}
239+
240+
visitERBUnlessNode(node: Nodes.ERBUnlessNode): void {
241+
this.printERBNode(node)
242+
243+
if (node.statements) {
244+
this.indentLevel++
245+
node.statements.forEach(statement => this.visit(statement))
246+
this.indentLevel--
247+
}
248+
249+
if (node.else_clause) {
250+
this.visit(node.else_clause)
251+
}
252+
253+
if (node.end_node) {
254+
this.visit(node.end_node)
255+
}
256+
}
257+
258+
/**
259+
* Write content, replacing leading whitespace on each line with the current indent.
260+
*
261+
* Uses a pendingIndent mechanism: when content ends with a newline followed by
262+
* whitespace-only, sets pendingIndent=true instead of writing the indent immediately.
263+
* The indent is then applied at the correct level when the next node writes content
264+
* (via the overridden write() method).
265+
*/
266+
protected writeWithIndent(content: string): void {
267+
if (!content.includes("\n")) {
268+
if (this.pendingIndent) {
269+
this.pendingIndent = false
270+
271+
const trimmed = content.replace(/^[ \t]+/, "")
272+
273+
if (trimmed.length > 0) {
274+
this.context.write(this.indent + trimmed)
275+
}
276+
} else {
277+
this.context.write(content)
278+
}
279+
280+
return
281+
}
282+
283+
const lines = content.split("\n")
284+
const lastIndex = lines.length - 1
285+
286+
for (let i = 0; i < lines.length; i++) {
287+
if (i > 0) {
288+
this.context.write("\n")
289+
}
290+
291+
const line = lines[i]
292+
const trimmed = line.replace(/^[ \t]+/, "")
293+
294+
if (i === 0) {
295+
if (this.pendingIndent) {
296+
this.pendingIndent = false
297+
298+
if (trimmed.length > 0) {
299+
this.context.write(this.indent + trimmed)
300+
}
301+
} else {
302+
this.context.write(line)
303+
}
304+
} else if (i === lastIndex && trimmed.length === 0) {
305+
this.pendingIndent = true
306+
} else if (trimmed.length === 0) {
307+
// Middle whitespace-only line: write nothing (newline already written above)
308+
} else {
309+
this.context.write(this.indent + trimmed)
310+
}
311+
}
312+
}
313+
}

javascript/packages/printer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { IdentityPrinter } from "./identity-printer.js"
2+
export { IndentPrinter } from "./indent-printer.js"
23
export { ERBToRubyStringPrinter } from "./erb-to-ruby-string-printer.js"
34
export { PrintContext } from "./print-context.js"
45
export { Printer, DEFAULT_PRINT_OPTIONS } from "./printer.js"

0 commit comments

Comments
 (0)