@herb-tools/formatter 0.4.0

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.
package/src/printer.ts ADDED
@@ -0,0 +1,635 @@
1
+ import { Visitor } from "@herb-tools/core"
2
+
3
+ import {
4
+ Node,
5
+ DocumentNode,
6
+ HTMLOpenTagNode,
7
+ HTMLCloseTagNode,
8
+ HTMLSelfCloseTagNode,
9
+ HTMLElementNode,
10
+ HTMLAttributeNode,
11
+ HTMLAttributeValueNode,
12
+ HTMLAttributeNameNode,
13
+ HTMLTextNode,
14
+ HTMLCommentNode,
15
+ HTMLDoctypeNode,
16
+ LiteralNode,
17
+ WhitespaceNode,
18
+ ERBContentNode,
19
+ ERBBlockNode,
20
+ ERBEndNode,
21
+ ERBElseNode,
22
+ ERBIfNode,
23
+ ERBWhenNode,
24
+ ERBCaseNode,
25
+ ERBCaseMatchNode,
26
+ ERBWhileNode,
27
+ ERBUntilNode,
28
+ ERBForNode,
29
+ ERBRescueNode,
30
+ ERBEnsureNode,
31
+ ERBBeginNode,
32
+ ERBUnlessNode,
33
+ ERBYieldNode,
34
+ ERBInNode,
35
+ Token
36
+ } from "@herb-tools/core"
37
+
38
+ type ERBNode =
39
+ ERBContentNode
40
+ | ERBBlockNode
41
+ | ERBEndNode
42
+ | ERBElseNode
43
+ | ERBIfNode
44
+ | ERBWhenNode
45
+ | ERBCaseNode
46
+ | ERBCaseMatchNode
47
+ | ERBWhileNode
48
+ | ERBUntilNode
49
+ | ERBForNode
50
+ | ERBRescueNode
51
+ | ERBEnsureNode
52
+ | ERBBeginNode
53
+ | ERBUnlessNode
54
+ | ERBYieldNode
55
+ | ERBInNode
56
+
57
+
58
+ import type { FormatOptions } from "./options.js"
59
+
60
+ /**
61
+ * Printer traverses the Herb AST using the Visitor pattern
62
+ * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
63
+ */
64
+ export class Printer extends Visitor {
65
+ private indentWidth: number
66
+ private maxLineLength: number
67
+ private source: string
68
+ private lines: string[] = []
69
+ private indentLevel: number = 0
70
+
71
+ constructor(source: string, options: Required<FormatOptions>) {
72
+ super()
73
+ this.source = source
74
+ this.indentWidth = options.indentWidth
75
+ this.maxLineLength = options.maxLineLength
76
+ }
77
+
78
+ print(object: Node | Token, indentLevel: number = 0): string {
79
+ if (object instanceof Token || (object as any).type?.startsWith('TOKEN_')) {
80
+ return (object as Token).value
81
+ }
82
+
83
+ const node: Node = object
84
+
85
+ this.lines = []
86
+ this.indentLevel = indentLevel
87
+
88
+ if (typeof (node as any).accept === 'function') {
89
+ node.accept(this)
90
+ } else {
91
+ this.visit(node)
92
+ }
93
+
94
+ return this.lines.filter(Boolean).join("\n")
95
+ }
96
+
97
+ private push(line: string) {
98
+ this.lines.push(line)
99
+ }
100
+
101
+ private withIndent<T>(callback: () => T): T {
102
+ this.indentLevel++
103
+ const result = callback()
104
+ this.indentLevel--
105
+ return result
106
+ }
107
+
108
+ private indent(): string {
109
+ return " ".repeat(this.indentLevel * this.indentWidth)
110
+ }
111
+
112
+ /**
113
+ * Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
114
+ */
115
+ private printERBNode(node: ERBNode): void {
116
+ const indent = this.indent()
117
+ const open = node.tag_opening?.value ?? ""
118
+ const close = node.tag_closing?.value ?? ""
119
+ let inner: string
120
+ if (node.tag_opening && node.tag_closing) {
121
+ const [, openingEnd] = node.tag_opening.range.toArray()
122
+ const [closingStart] = node.tag_closing.range.toArray()
123
+ const rawInner = this.source.slice(openingEnd, closingStart)
124
+ inner = ` ${rawInner.trim()} `
125
+ } else {
126
+ const txt = node.content?.value ?? ""
127
+ inner = txt.trim() ? ` ${txt.trim()} ` : ""
128
+ }
129
+ this.push(indent + open + inner + close)
130
+ }
131
+
132
+ // --- Visitor methods ---
133
+
134
+ visitDocumentNode(node: DocumentNode): void {
135
+ node.children.forEach(child => this.visit(child))
136
+ }
137
+
138
+ visitHTMLElementNode(node: HTMLElementNode): void {
139
+ const open = node.open_tag as HTMLOpenTagNode
140
+ const tagName = open.tag_name?.value ?? ""
141
+ const indent = this.indent()
142
+
143
+ const attributes = open.children.filter((child): child is HTMLAttributeNode =>
144
+ child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
145
+ )
146
+ const children = node.body.filter(
147
+ child =>
148
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
149
+ !((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
150
+ )
151
+
152
+ const hasClosing = open.tag_closing?.value === ">" || open.tag_closing?.value === "/>"
153
+ const isSelfClosing = open.tag_closing?.value === "/>"
154
+
155
+ if (!hasClosing) {
156
+ this.push(indent + `<${tagName}`)
157
+
158
+ return
159
+ }
160
+
161
+ if (attributes.length === 0) {
162
+ if (children.length === 0) {
163
+ if (isSelfClosing) {
164
+ this.push(indent + `<${tagName} />`)
165
+ } else if (node.is_void) {
166
+ this.push(indent + `<${tagName}>`)
167
+ } else {
168
+ this.push(indent + `<${tagName}></${tagName}>`)
169
+ }
170
+ return
171
+ }
172
+
173
+ this.push(indent + `<${tagName}>`)
174
+
175
+ this.withIndent(() => {
176
+ children.forEach(child => this.visit(child))
177
+ })
178
+
179
+ if (!node.is_void && !isSelfClosing) {
180
+ this.push(indent + `</${tagName}>`)
181
+ }
182
+
183
+ return
184
+ }
185
+
186
+ const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing)
187
+ const singleAttribute = attributes[0]
188
+ const hasEmptyValue =
189
+ singleAttribute &&
190
+ (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
191
+ (singleAttribute.value as any)?.children.length === 0
192
+
193
+ const shouldKeepInline = attributes.length <= 3 &&
194
+ !hasEmptyValue &&
195
+ inline.length + indent.length <= this.maxLineLength
196
+
197
+ if (shouldKeepInline) {
198
+ if (children.length === 0) {
199
+ if (isSelfClosing) {
200
+ this.push(indent + inline)
201
+ } else if (node.is_void) {
202
+ this.push(indent + inline)
203
+ } else {
204
+ this.push(indent + inline.replace('>', `></${tagName}>`))
205
+ }
206
+ return
207
+ }
208
+
209
+ if (isSelfClosing) {
210
+ this.push(indent + inline.replace(' />', '>'))
211
+ } else {
212
+ this.push(indent + inline)
213
+ }
214
+
215
+ this.withIndent(() => {
216
+ children.forEach(child => this.visit(child))
217
+ })
218
+
219
+ if (!node.is_void && !isSelfClosing) {
220
+ this.push(indent + `</${tagName}>`)
221
+ }
222
+
223
+ return
224
+ }
225
+
226
+ this.push(indent + `<${tagName}`)
227
+ this.withIndent(() => {
228
+ attributes.forEach(attribute => {
229
+ this.push(this.indent() + this.renderAttribute(attribute))
230
+ })
231
+ })
232
+
233
+ if (isSelfClosing) {
234
+ this.push(indent + "/>")
235
+ } else if (node.is_void) {
236
+ this.push(indent + ">")
237
+ } else if (children.length === 0) {
238
+ this.push(indent + ">" + `</${tagName}>`)
239
+ } else {
240
+ this.push(indent + ">")
241
+
242
+ this.withIndent(() => {
243
+ children.forEach(child => this.visit(child))
244
+ })
245
+
246
+ this.push(indent + `</${tagName}>`)
247
+ }
248
+ }
249
+
250
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
251
+ const tagName = node.tag_name?.value ?? ""
252
+ const indent = this.indent()
253
+ const attributes = node.children.filter((attribute): attribute is HTMLAttributeNode =>
254
+ attribute instanceof HTMLAttributeNode || (attribute as any).type === 'AST_HTML_ATTRIBUTE_NODE'
255
+ )
256
+
257
+ const hasClosing = node.tag_closing?.value === ">"
258
+
259
+ if (!hasClosing) {
260
+ this.push(indent + `<${tagName}`)
261
+ return
262
+ }
263
+
264
+ const inline = this.renderInlineOpen(tagName, attributes, node.is_void)
265
+
266
+ if (attributes.length === 0 || inline.length + indent.length <= this.maxLineLength) {
267
+ this.push(indent + inline)
268
+
269
+ return
270
+ }
271
+
272
+ this.push(indent + `<${tagName}`)
273
+ this.withIndent(() => {
274
+ attributes.forEach(attribute => {
275
+ this.push(this.indent() + this.renderAttribute(attribute))
276
+ })
277
+ })
278
+ this.push(indent + (node.is_void ? "/>" : ">"))
279
+ }
280
+
281
+ visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
282
+ const tagName = node.tag_name?.value ?? ""
283
+ const indent = this.indent()
284
+ const attributes = node.attributes.filter((attribute): attribute is HTMLAttributeNode =>
285
+ attribute instanceof HTMLAttributeNode || (attribute as any).type === 'AST_HTML_ATTRIBUTE_NODE'
286
+ )
287
+ const inline = this.renderInlineOpen(tagName, attributes, true)
288
+
289
+ const singleAttribute = attributes[0]
290
+ const hasEmptyValue =
291
+ singleAttribute &&
292
+ (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
293
+ (singleAttribute.value as any)?.children.length === 0
294
+
295
+ const shouldKeepInline = attributes.length <= 3 &&
296
+ !hasEmptyValue &&
297
+ inline.length + indent.length <= this.maxLineLength
298
+
299
+ if (shouldKeepInline) {
300
+ this.push(indent + inline)
301
+ return
302
+ }
303
+
304
+ this.push(indent + `<${tagName}`)
305
+ this.withIndent(() => {
306
+ attributes.forEach(attribute => {
307
+ this.push(this.indent() + this.renderAttribute(attribute))
308
+ })
309
+ })
310
+ this.push(indent + "/>")
311
+ }
312
+
313
+ visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
314
+ const indent = this.indent()
315
+ const open = node.tag_opening?.value ?? ""
316
+ const name = node.tag_name?.value ?? ""
317
+ const close = node.tag_closing?.value ?? ""
318
+
319
+ this.push(indent + open + name + close)
320
+ }
321
+
322
+ visitHTMLTextNode(node: HTMLTextNode): void {
323
+ const indent = this.indent()
324
+ let text = node.content.trim()
325
+
326
+ if (!text) return
327
+
328
+ const wrapWidth = this.maxLineLength - indent.length
329
+ const words = text.split(/\s+/)
330
+ const lines: string[] = []
331
+
332
+ let line = ""
333
+
334
+ for (const word of words) {
335
+ if ((line + (line ? " " : "") + word).length > wrapWidth && line) {
336
+ lines.push(indent + line)
337
+ line = word
338
+ } else {
339
+ line += (line ? " " : "") + word
340
+ }
341
+ }
342
+
343
+ if (line) lines.push(indent + line)
344
+
345
+ lines.forEach(line => this.push(line))
346
+ }
347
+
348
+ visitHTMLAttributeNode(node: HTMLAttributeNode): void {
349
+ const indent = this.indent()
350
+ this.push(indent + this.renderAttribute(node))
351
+ }
352
+
353
+ visitHTMLAttributeNameNode(node: HTMLAttributeNameNode): void {
354
+ const indent = this.indent()
355
+ const name = node.name?.value ?? ""
356
+ this.push(indent + name)
357
+ }
358
+
359
+ visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void {
360
+ const indent = this.indent()
361
+ const open_quote = node.open_quote?.value ?? ""
362
+ const close_quote = node.close_quote?.value ?? ""
363
+ const attribute_value = node.children.map(child => {
364
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' ||
365
+ child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
366
+ return (child as HTMLTextNode | LiteralNode).content
367
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
368
+ const erbChild = child as ERBContentNode
369
+ return (erbChild.tag_opening!.value + erbChild.content!.value + erbChild.tag_closing!.value)
370
+ }
371
+ return ""
372
+ }).join("")
373
+ this.push(indent + open_quote + attribute_value + close_quote)
374
+ }
375
+
376
+ visitHTMLCommentNode(node: HTMLCommentNode): void {
377
+ const indent = this.indent()
378
+ const open = node.comment_start?.value ?? ""
379
+ const close = node.comment_end?.value ?? ""
380
+ let inner: string
381
+
382
+ if (node.comment_start && node.comment_end) {
383
+ // TODO: use .value
384
+ const [_, startIndex] = node.comment_start.range.toArray()
385
+ const [endIndex] = node.comment_end.range.toArray()
386
+ const rawInner = this.source.slice(startIndex, endIndex)
387
+ inner = ` ${rawInner.trim()} `
388
+ } else {
389
+ inner = node.children.map(child => {
390
+ const prevLines = this.lines.length
391
+ this.visit(child)
392
+ return this.lines.slice(prevLines).join("")
393
+ }).join("")
394
+ }
395
+
396
+ this.push(indent + open + inner + close)
397
+ }
398
+
399
+ visitERBCommentNode(node: ERBContentNode): void {
400
+ const indent = this.indent()
401
+ const open = node.tag_opening?.value ?? ""
402
+ const close = node.tag_closing?.value ?? ""
403
+ let inner: string
404
+
405
+ if (node.tag_opening && node.tag_closing) {
406
+ const [, openingEnd] = node.tag_opening.range.toArray()
407
+ const [closingStart] = node.tag_closing.range.toArray()
408
+ const rawInner = this.source.slice(openingEnd, closingStart)
409
+ const lines = rawInner.split("\n")
410
+ if (lines.length > 2) {
411
+ const childIndent = indent + " ".repeat(this.indentWidth)
412
+ const innerLines = lines.slice(1, -1).map(line => childIndent + line.trim())
413
+ inner = "\n" + innerLines.join("\n") + "\n"
414
+ } else {
415
+ inner = ` ${rawInner.trim()} `
416
+ }
417
+ } else {
418
+ inner = (node as any).children
419
+ .map((child: any) => {
420
+ const prevLines = this.lines.length
421
+ this.visit(child)
422
+ return this.lines.slice(prevLines).join("")
423
+ })
424
+ .join("")
425
+ }
426
+
427
+ this.push(indent + open + inner + close)
428
+ }
429
+
430
+ visitHTMLDoctypeNode(node: HTMLDoctypeNode): void {
431
+ const indent = this.indent()
432
+ const open = node.tag_opening?.value ?? ""
433
+ let innerDoctype: string
434
+
435
+ if (node.tag_opening && node.tag_closing) {
436
+ // TODO: use .value
437
+ const [, openingEnd] = node.tag_opening.range.toArray()
438
+ const [closingStart] = node.tag_closing.range.toArray()
439
+ innerDoctype = this.source.slice(openingEnd, closingStart)
440
+ } else {
441
+ innerDoctype = node.children
442
+ .map(child =>
443
+ child instanceof HTMLTextNode ? child.content : (() => { const prevLines = this.lines.length; this.visit(child); return this.lines.slice(prevLines).join("") })(),
444
+ )
445
+ .join("")
446
+ }
447
+
448
+ const close = node.tag_closing?.value ?? ""
449
+ this.push(indent + open + innerDoctype + close)
450
+ }
451
+
452
+ visitERBContentNode(node: ERBContentNode): void {
453
+ // TODO: this feels hacky
454
+ if (node.tag_opening?.value === "<%#") {
455
+ this.visitERBCommentNode(node)
456
+ } else {
457
+ this.printERBNode(node)
458
+ }
459
+ }
460
+
461
+ visitERBEndNode(node: ERBEndNode): void {
462
+ this.printERBNode(node)
463
+ }
464
+
465
+ visitERBYieldNode(node: ERBYieldNode): void {
466
+ this.printERBNode(node)
467
+ }
468
+
469
+ visitERBInNode(node: ERBInNode): void {
470
+ this.printERBNode(node)
471
+ }
472
+
473
+ visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
474
+ this.printERBNode(node)
475
+ }
476
+
477
+ visitERBBlockNode(node: ERBBlockNode): void {
478
+ const indent = this.indent()
479
+ const open = node.tag_opening?.value ?? ""
480
+ const content = node.content?.value ?? ""
481
+ const close = node.tag_closing?.value ?? ""
482
+
483
+ this.push(indent + open + content + close)
484
+
485
+ this.withIndent(() => {
486
+ node.body.forEach(child => this.visit(child))
487
+ })
488
+
489
+ if (node.end_node) {
490
+ this.visit(node.end_node)
491
+ }
492
+ }
493
+
494
+ visitERBIfNode(node: ERBIfNode): void {
495
+ this.printERBNode(node)
496
+
497
+ this.withIndent(() => {
498
+ node.statements.forEach(child => this.visit(child))
499
+ })
500
+
501
+ if (node.subsequent) {
502
+ this.visit(node.subsequent)
503
+ }
504
+
505
+ if (node.end_node) {
506
+ this.printERBNode(node.end_node as any)
507
+ }
508
+ }
509
+
510
+ visitERBElseNode(node: ERBElseNode): void {
511
+ this.printERBNode(node)
512
+
513
+ this.withIndent(() => {
514
+ node.statements.forEach(child => this.visit(child))
515
+ })
516
+ }
517
+
518
+ visitERBWhenNode(node: ERBWhenNode): void {
519
+ this.printERBNode(node)
520
+
521
+ this.withIndent(() => {
522
+ node.statements.forEach(stmt => this.visit(stmt))
523
+ })
524
+ }
525
+
526
+ visitERBCaseNode(node: ERBCaseNode): void {
527
+ const baseLevel = this.indentLevel
528
+ const indent = this.indent()
529
+ const open = node.tag_opening?.value ?? ""
530
+ const content = node.content?.value ?? ""
531
+ const close = node.tag_closing?.value ?? ""
532
+ this.push(indent + open + content + close)
533
+
534
+ node.conditions.forEach(condition => this.visit(condition))
535
+ if (node.else_clause) this.visit(node.else_clause)
536
+
537
+ if (node.end_node) {
538
+ this.visit(node.end_node)
539
+ }
540
+ }
541
+
542
+ visitERBBeginNode(node: ERBBeginNode): void {
543
+ const indent = this.indent()
544
+ const open = node.tag_opening?.value ?? ""
545
+ const content = node.content?.value ?? ""
546
+ const close = node.tag_closing?.value ?? ""
547
+
548
+ this.push(indent + open + content + close)
549
+
550
+ this.withIndent(() => {
551
+ node.statements.forEach(statement => this.visit(statement))
552
+ })
553
+
554
+ if (node.rescue_clause) this.visit(node.rescue_clause)
555
+ if (node.else_clause) this.visit(node.else_clause)
556
+ if (node.ensure_clause) this.visit(node.ensure_clause)
557
+ if (node.end_node) this.visit(node.end_node)
558
+ }
559
+
560
+ visitERBWhileNode(node: ERBWhileNode): void {
561
+ this.visitERBGeneric(node)
562
+ }
563
+
564
+ visitERBUntilNode(node: ERBUntilNode): void {
565
+ this.visitERBGeneric(node)
566
+ }
567
+
568
+ visitERBForNode(node: ERBForNode): void {
569
+ this.visitERBGeneric(node)
570
+ }
571
+
572
+ visitERBRescueNode(node: ERBRescueNode): void {
573
+ this.visitERBGeneric(node)
574
+ }
575
+
576
+ visitERBEnsureNode(node: ERBEnsureNode): void {
577
+ this.visitERBGeneric(node)
578
+ }
579
+
580
+ visitERBUnlessNode(node: ERBUnlessNode): void {
581
+ this.visitERBGeneric(node)
582
+ }
583
+
584
+ // TODO: don't use any
585
+ private visitERBGeneric(node: any): void {
586
+ const indent = this.indent()
587
+ const open = node.tag_opening?.value ?? ""
588
+ const content = node.content?.value ?? ""
589
+ const close = node.tag_closing?.value ?? ""
590
+
591
+ this.push(indent + open + content + close)
592
+
593
+ this.withIndent(() => {
594
+ const statements: any[] = node.statements ?? node.body ?? node.children ?? []
595
+
596
+ statements.forEach(statement => this.visit(statement))
597
+ })
598
+
599
+ if (node.end_node) this.visit(node.end_node)
600
+ }
601
+
602
+ // --- Utility methods ---
603
+
604
+ private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean): string {
605
+ const parts = attributes.map(attribute => this.renderAttribute(attribute))
606
+
607
+ return `<${name}${parts.length ? " " + parts.join(" ") : ""}${selfClose ? " /" : ""}>`
608
+ }
609
+
610
+ renderAttribute(attribute: HTMLAttributeNode): string {
611
+ const name = (attribute.name as HTMLAttributeNameNode)!.name!.value ?? ""
612
+ const equals = attribute.equals?.value ?? ""
613
+ let value = ""
614
+
615
+ if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
616
+ const attrValue = attribute.value as HTMLAttributeValueNode
617
+ const open_quote = (attrValue.open_quote?.value ?? "")
618
+ const close_quote = (attrValue.close_quote?.value ?? "")
619
+ const attribute_value = attrValue.children.map((attr: any) => {
620
+ if (attr instanceof HTMLTextNode || (attr as any).type === 'AST_HTML_TEXT_NODE' ||
621
+ attr instanceof LiteralNode || (attr as any).type === 'AST_LITERAL_NODE') {
622
+ return (attr as HTMLTextNode | LiteralNode).content
623
+ } else if (attr instanceof ERBContentNode || (attr as any).type === 'AST_ERB_CONTENT_NODE') {
624
+ const erbAttr = attr as ERBContentNode
625
+ return (erbAttr.tag_opening!.value + erbAttr.content!.value + erbAttr.tag_closing!.value)
626
+ }
627
+ return ""
628
+ }).join("")
629
+
630
+ value = open_quote + attribute_value + close_quote
631
+ }
632
+
633
+ return name + equals + value
634
+ }
635
+ }