@herb-tools/printer 0.6.0 → 0.6.1

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.
@@ -0,0 +1,309 @@
1
+ import { IdentityPrinter } from "./identity-printer.js"
2
+ import { PrintOptions, DEFAULT_PRINT_OPTIONS } from "./printer.js"
3
+ import { isERBOutputNode, filterNodes, ERBContentNode, } from "@herb-tools/core"
4
+
5
+ import { HTMLTextNode, ERBIfNode, ERBElseNode, ERBUnlessNode, Node, HTMLAttributeValueNode } from "@herb-tools/core"
6
+
7
+ export interface ERBToRubyStringOptions extends PrintOptions {
8
+ /**
9
+ * Whether to force wrapping the output in double quotes even for single ERB nodes
10
+ * @default false
11
+ */
12
+ forceQuotes?: boolean
13
+ }
14
+
15
+ export const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS: ERBToRubyStringOptions = {
16
+ ...DEFAULT_PRINT_OPTIONS,
17
+ forceQuotes: false
18
+ }
19
+
20
+ /**
21
+ * ERBToRubyStringPrinter - Converts ERB snippets to Ruby strings with interpolation
22
+ *
23
+ * This printer transforms ERB templates into Ruby strings by:
24
+ * - Converting literal text to string content
25
+ * - Converting <%= %> tags to #{} interpolation
26
+ * - Converting simple if/else blocks to ternary operators
27
+ * - Ignoring <% %> tags (they don't produce output)
28
+ *
29
+ * Examples:
30
+ * - `hello world <%= hello %>` => `"hello world #{hello}"`
31
+ * - `hello world <% hello %>` => `"hello world "`
32
+ * - `Welcome <%= user.name %>!` => `"Welcome #{user.name}!"`
33
+ * - `<% if logged_in? %>Welcome<% else %>Login<% end %>` => `"logged_in? ? "Welcome" : "Login"`
34
+ * - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
35
+ */
36
+ export class ERBToRubyStringPrinter extends IdentityPrinter {
37
+
38
+ // TODO: cleanup `.type === "AST_*" checks`
39
+ static print(node: Node, options: Partial<ERBToRubyStringOptions> = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS): string {
40
+ const erbNodes = filterNodes([node], ERBContentNode)
41
+
42
+ if (erbNodes.length === 1 && isERBOutputNode(erbNodes[0]) && !options.forceQuotes) {
43
+ return (erbNodes[0].content?.value || "").trim()
44
+ }
45
+
46
+ if ('children' in node && Array.isArray(node.children)) {
47
+ const childErbNodes = filterNodes(node.children, ERBContentNode)
48
+ const hasOnlyERBContent = node.children.length > 0 && node.children.length === childErbNodes.length
49
+
50
+ if (hasOnlyERBContent && childErbNodes.length === 1 && isERBOutputNode(childErbNodes[0]) && !options.forceQuotes) {
51
+ return (childErbNodes[0].content?.value || "").trim()
52
+ }
53
+
54
+ if (node.children.length === 1 && node.children[0].type === "AST_ERB_IF_NODE" && !options.forceQuotes) {
55
+ const ifNode = node.children[0] as ERBIfNode
56
+ const printer = new ERBToRubyStringPrinter()
57
+
58
+ if (printer.canConvertToTernary(ifNode)) {
59
+ printer.convertToTernaryWithoutWrapper(ifNode)
60
+ return printer.context.getOutput()
61
+ }
62
+ }
63
+
64
+ if (node.children.length === 1 && node.children[0].type === "AST_ERB_UNLESS_NODE" && !options.forceQuotes) {
65
+ const unlessNode = node.children[0] as ERBUnlessNode
66
+ const printer = new ERBToRubyStringPrinter()
67
+
68
+ if (printer.canConvertUnlessToTernary(unlessNode)) {
69
+ printer.convertUnlessToTernaryWithoutWrapper(unlessNode)
70
+ return printer.context.getOutput()
71
+ }
72
+ }
73
+ }
74
+
75
+ const printer = new ERBToRubyStringPrinter()
76
+
77
+ printer.context.write('"')
78
+
79
+ printer.visit(node)
80
+
81
+ printer.context.write('"')
82
+
83
+ return printer.context.getOutput()
84
+ }
85
+
86
+ visitHTMLTextNode(node: HTMLTextNode) {
87
+ if (node.content) {
88
+ const escapedContent = node.content.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
89
+ this.context.write(escapedContent)
90
+ }
91
+ }
92
+
93
+ visitERBContentNode(node: ERBContentNode) {
94
+ if (isERBOutputNode(node)) {
95
+ this.context.write("#{")
96
+
97
+ if (node.content?.value) {
98
+ this.context.write(node.content.value.trim())
99
+ }
100
+
101
+ this.context.write("}")
102
+ }
103
+ }
104
+
105
+ visitERBIfNode(node: ERBIfNode) {
106
+ if (this.canConvertToTernary(node)) {
107
+ this.convertToTernary(node)
108
+ }
109
+ }
110
+
111
+ visitERBUnlessNode(node: ERBUnlessNode) {
112
+ if (this.canConvertUnlessToTernary(node)) {
113
+ this.convertUnlessToTernary(node)
114
+ }
115
+ }
116
+
117
+ visitHTMLAttributeValueNode(node: HTMLAttributeValueNode) {
118
+ this.visitChildNodes(node)
119
+ }
120
+
121
+ private canConvertToTernary(node: ERBIfNode): boolean {
122
+ if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
123
+ return false
124
+ }
125
+
126
+ const ifOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true
127
+ if (!ifOnlyText) return false
128
+
129
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE") {
130
+ return (node.subsequent as ERBElseNode).statements
131
+ ? (node.subsequent as ERBElseNode).statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
132
+ : true
133
+ }
134
+
135
+ return true
136
+ }
137
+
138
+ private convertToTernary(node: ERBIfNode) {
139
+ this.context.write("#{")
140
+
141
+ if (node.content?.value) {
142
+ const condition = node.content.value.trim()
143
+ const cleanCondition = condition.replace(/^if\s+/, '')
144
+ const needsParentheses = cleanCondition.includes(' ')
145
+
146
+ if (needsParentheses) {
147
+ this.context.write("(")
148
+ }
149
+
150
+ this.context.write(cleanCondition)
151
+
152
+ if (needsParentheses) {
153
+ this.context.write(")")
154
+ }
155
+ }
156
+
157
+ this.context.write(" ? ")
158
+ this.context.write('"')
159
+
160
+ if (node.statements) {
161
+ node.statements.forEach(statement => this.visit(statement))
162
+ }
163
+
164
+ this.context.write('"')
165
+ this.context.write(" : ")
166
+ this.context.write('"')
167
+
168
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && (node.subsequent as ERBElseNode).statements) {
169
+ (node.subsequent as ERBElseNode).statements.forEach(statement => this.visit(statement))
170
+ }
171
+
172
+ this.context.write('"')
173
+ this.context.write("}")
174
+ }
175
+
176
+ private convertToTernaryWithoutWrapper(node: ERBIfNode) {
177
+ if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
178
+ return false
179
+ }
180
+
181
+ if (node.content?.value) {
182
+ const condition = node.content.value.trim()
183
+ const cleanCondition = condition.replace(/^if\s+/, '')
184
+ const needsParentheses = cleanCondition.includes(' ')
185
+
186
+ if (needsParentheses) {
187
+ this.context.write("(")
188
+ }
189
+
190
+ this.context.write(cleanCondition)
191
+
192
+ if (needsParentheses) {
193
+ this.context.write(")")
194
+ }
195
+ }
196
+
197
+ this.context.write(" ? ")
198
+ this.context.write('"')
199
+
200
+ if (node.statements) {
201
+ node.statements.forEach(statement => this.visit(statement))
202
+ }
203
+
204
+ this.context.write('"')
205
+ this.context.write(" : ")
206
+ this.context.write('"')
207
+
208
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && (node.subsequent as ERBElseNode).statements) {
209
+ (node.subsequent as ERBElseNode).statements.forEach(statement => this.visit(statement))
210
+ }
211
+
212
+ this.context.write('"')
213
+ }
214
+
215
+ private canConvertUnlessToTernary(node: ERBUnlessNode): boolean {
216
+ const unlessOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true
217
+
218
+ if (!unlessOnlyText) return false
219
+
220
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
221
+ return node.else_clause.statements
222
+ ? node.else_clause.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
223
+ : true
224
+ }
225
+
226
+ return true
227
+ }
228
+
229
+ private convertUnlessToTernary(node: ERBUnlessNode) {
230
+ this.context.write("#{")
231
+
232
+ if (node.content?.value) {
233
+ const condition = node.content.value.trim()
234
+ const cleanCondition = condition.replace(/^unless\s+/, '')
235
+ const needsParentheses = cleanCondition.includes(' ')
236
+
237
+ this.context.write("!(")
238
+
239
+ if (needsParentheses) {
240
+ this.context.write("(")
241
+ }
242
+
243
+ this.context.write(cleanCondition)
244
+
245
+ if (needsParentheses) {
246
+ this.context.write(")")
247
+ }
248
+
249
+ this.context.write(")")
250
+ }
251
+
252
+ this.context.write(" ? ")
253
+ this.context.write('"')
254
+
255
+ if (node.statements) {
256
+ node.statements.forEach(statement => this.visit(statement))
257
+ }
258
+
259
+ this.context.write('"')
260
+ this.context.write(" : ")
261
+ this.context.write('"')
262
+
263
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
264
+ node.else_clause.statements.forEach(statement => this.visit(statement))
265
+ }
266
+
267
+ this.context.write('"')
268
+ this.context.write("}")
269
+ }
270
+
271
+ private convertUnlessToTernaryWithoutWrapper(node: ERBUnlessNode) {
272
+ if (node.content?.value) {
273
+ const condition = node.content.value.trim()
274
+ const cleanCondition = condition.replace(/^unless\s+/, '')
275
+ const needsParentheses = cleanCondition.includes(' ')
276
+
277
+ this.context.write("!(")
278
+
279
+ if (needsParentheses) {
280
+ this.context.write("(")
281
+ }
282
+
283
+ this.context.write(cleanCondition)
284
+
285
+ if (needsParentheses) {
286
+ this.context.write(")")
287
+ }
288
+
289
+ this.context.write(")")
290
+ }
291
+
292
+ this.context.write(" ? ")
293
+ this.context.write('"')
294
+
295
+ if (node.statements) {
296
+ node.statements.forEach(statement => this.visit(statement))
297
+ }
298
+
299
+ this.context.write('"')
300
+ this.context.write(" : ")
301
+ this.context.write('"')
302
+
303
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
304
+ node.else_clause.statements.forEach(statement => this.visit(statement))
305
+ }
306
+
307
+ this.context.write('"')
308
+ }
309
+ }
@@ -1,4 +1,7 @@
1
1
  import { Printer } from "./printer.js"
2
+ import { getNodesBeforePosition, getNodesAfterPosition } from "@herb-tools/core"
3
+
4
+ import type * as Nodes from "@herb-tools/core"
2
5
 
3
6
  /**
4
7
  * IdentityPrinter - Provides lossless reconstruction of the original source
@@ -10,5 +13,373 @@ import { Printer } from "./printer.js"
10
13
  * - Verifying AST round-trip fidelity
11
14
  */
12
15
  export class IdentityPrinter extends Printer {
16
+ visitLiteralNode(node: Nodes.LiteralNode): void {
17
+ this.write(node.content)
18
+ }
19
+
20
+ visitHTMLTextNode(node: Nodes.HTMLTextNode): void {
21
+ this.write(node.content)
22
+ }
23
+
24
+ visitWhitespaceNode(node: Nodes.WhitespaceNode): void {
25
+ if (node.value) {
26
+ this.write(node.value.value)
27
+ }
28
+ }
29
+
30
+ visitHTMLOpenTagNode(node: Nodes.HTMLOpenTagNode): void {
31
+ if (node.tag_opening) {
32
+ this.write(node.tag_opening.value)
33
+ }
34
+
35
+ if (node.tag_name) {
36
+ this.write(node.tag_name.value)
37
+ }
38
+
39
+ this.visitChildNodes(node)
40
+
41
+ if (node.tag_closing) {
42
+ this.write(node.tag_closing.value)
43
+ }
44
+ }
45
+
46
+ visitHTMLCloseTagNode(node: Nodes.HTMLCloseTagNode): void {
47
+ if (node.tag_opening) {
48
+ this.write(node.tag_opening.value)
49
+ }
50
+
51
+ if (node.tag_name) {
52
+ const before = getNodesBeforePosition(node.children, node.tag_name.location.start, true)
53
+ const after = getNodesAfterPosition(node.children, node.tag_name.location.end)
54
+
55
+ this.visitAll(before)
56
+ this.write(node.tag_name.value)
57
+ this.visitAll(after)
58
+ } else {
59
+ this.visitAll(node.children)
60
+ }
61
+
62
+ if (node.tag_closing) {
63
+ this.write(node.tag_closing.value)
64
+ }
65
+ }
66
+
67
+ visitHTMLElementNode(node: Nodes.HTMLElementNode): void {
68
+ const tagName = node.tag_name?.value
69
+
70
+ if (tagName) {
71
+ this.context.enterTag(tagName)
72
+ }
73
+
74
+ if (node.open_tag) {
75
+ this.visit(node.open_tag)
76
+ }
77
+
78
+ if (node.body) {
79
+ node.body.forEach(child => this.visit(child))
80
+ }
81
+
82
+ if (node.close_tag) {
83
+ this.visit(node.close_tag)
84
+ }
85
+
86
+ if (tagName) {
87
+ this.context.exitTag()
88
+ }
89
+ }
90
+
91
+ visitHTMLAttributeNode(node: Nodes.HTMLAttributeNode): void {
92
+ if (node.name) {
93
+ this.visit(node.name)
94
+ }
95
+
96
+ if (node.equals) {
97
+ this.write(node.equals.value)
98
+ }
99
+
100
+ if (node.equals && node.value) {
101
+ this.visit(node.value)
102
+ }
103
+ }
104
+
105
+ visitHTMLAttributeNameNode(node: Nodes.HTMLAttributeNameNode): void {
106
+ this.visitChildNodes(node)
107
+ }
108
+
109
+ visitHTMLAttributeValueNode(node: Nodes.HTMLAttributeValueNode): void {
110
+ if (node.quoted && node.open_quote) {
111
+ this.write(node.open_quote.value)
112
+ }
113
+
114
+ this.visitChildNodes(node)
115
+
116
+ if (node.quoted && node.close_quote) {
117
+ this.write(node.close_quote.value)
118
+ }
119
+ }
120
+
121
+ visitHTMLCommentNode(node: Nodes.HTMLCommentNode): void {
122
+ if (node.comment_start) {
123
+ this.write(node.comment_start.value)
124
+ }
125
+
126
+ this.visitChildNodes(node)
127
+
128
+ if (node.comment_end) {
129
+ this.write(node.comment_end.value)
130
+ }
131
+ }
132
+
133
+ visitHTMLDoctypeNode(node: Nodes.HTMLDoctypeNode): void {
134
+ if (node.tag_opening) {
135
+ this.write(node.tag_opening.value)
136
+ }
137
+
138
+ this.visitChildNodes(node)
139
+
140
+ if (node.tag_closing) {
141
+ this.write(node.tag_closing.value)
142
+ }
143
+ }
144
+
145
+ visitXMLDeclarationNode(node: Nodes.XMLDeclarationNode): void {
146
+ if (node.tag_opening) {
147
+ this.write(node.tag_opening.value)
148
+ }
149
+
150
+ this.visitChildNodes(node)
151
+
152
+ if (node.tag_closing) {
153
+ this.write(node.tag_closing.value)
154
+ }
155
+ }
156
+
157
+ visitCDATANode(node: Nodes.CDATANode): void {
158
+ if (node.tag_opening) {
159
+ this.write(node.tag_opening.value)
160
+ }
161
+
162
+ this.visitChildNodes(node)
163
+
164
+ if (node.tag_closing) {
165
+ this.write(node.tag_closing.value)
166
+ }
167
+ }
168
+
169
+ visitERBContentNode(node: Nodes.ERBContentNode): void {
170
+ this.printERBNode(node)
171
+ }
172
+
173
+ visitERBIfNode(node: Nodes.ERBIfNode): void {
174
+ this.printERBNode(node)
175
+
176
+ if (node.statements) {
177
+ node.statements.forEach(statement => this.visit(statement))
178
+ }
179
+
180
+ if (node.subsequent) {
181
+ this.visit(node.subsequent)
182
+ }
183
+
184
+ if (node.end_node) {
185
+ this.visit(node.end_node)
186
+ }
187
+ }
188
+
189
+ visitERBElseNode(node: Nodes.ERBElseNode): void {
190
+ this.printERBNode(node)
191
+
192
+ if (node.statements) {
193
+ node.statements.forEach(statement => this.visit(statement))
194
+ }
195
+ }
196
+
197
+ visitERBEndNode(node: Nodes.ERBEndNode): void {
198
+ this.printERBNode(node)
199
+ }
200
+
201
+ visitERBBlockNode(node: Nodes.ERBBlockNode): void {
202
+ this.printERBNode(node)
203
+
204
+ if (node.body) {
205
+ node.body.forEach(child => this.visit(child))
206
+ }
207
+
208
+ if (node.end_node) {
209
+ this.visit(node.end_node)
210
+ }
211
+ }
212
+
213
+ visitERBCaseNode(node: Nodes.ERBCaseNode): void {
214
+ this.printERBNode(node)
215
+
216
+ if (node.children) {
217
+ node.children.forEach(child => this.visit(child))
218
+ }
219
+
220
+ if (node.conditions) {
221
+ node.conditions.forEach(condition => this.visit(condition))
222
+ }
223
+
224
+ if (node.else_clause) {
225
+ this.visit(node.else_clause)
226
+ }
227
+
228
+ if (node.end_node) {
229
+ this.visit(node.end_node)
230
+ }
231
+ }
232
+
233
+ visitERBWhenNode(node: Nodes.ERBWhenNode): void {
234
+ this.printERBNode(node)
235
+
236
+ if (node.statements) {
237
+ node.statements.forEach(statement => this.visit(statement))
238
+ }
239
+ }
240
+
241
+ visitERBWhileNode(node: Nodes.ERBWhileNode): void {
242
+ this.printERBNode(node)
243
+
244
+ if (node.statements) {
245
+ node.statements.forEach(statement => this.visit(statement))
246
+ }
247
+
248
+ if (node.end_node) {
249
+ this.visit(node.end_node)
250
+ }
251
+ }
252
+
253
+ visitERBUntilNode(node: Nodes.ERBUntilNode): void {
254
+ this.printERBNode(node)
255
+
256
+ if (node.statements) {
257
+ node.statements.forEach(statement => this.visit(statement))
258
+ }
259
+
260
+ if (node.end_node) {
261
+ this.visit(node.end_node)
262
+ }
263
+ }
264
+
265
+ visitERBForNode(node: Nodes.ERBForNode): void {
266
+ this.printERBNode(node)
267
+
268
+ if (node.statements) {
269
+ node.statements.forEach(statement => this.visit(statement))
270
+ }
271
+
272
+ if (node.end_node) {
273
+ this.visit(node.end_node)
274
+ }
275
+ }
276
+
277
+ visitERBBeginNode(node: Nodes.ERBBeginNode): void {
278
+ this.printERBNode(node)
279
+
280
+ if (node.statements) {
281
+ node.statements.forEach(statement => this.visit(statement))
282
+ }
283
+
284
+ if (node.rescue_clause) {
285
+ this.visit(node.rescue_clause)
286
+ }
287
+
288
+ if (node.else_clause) {
289
+ this.visit(node.else_clause)
290
+ }
291
+
292
+ if (node.ensure_clause) {
293
+ this.visit(node.ensure_clause)
294
+ }
295
+
296
+ if (node.end_node) {
297
+ this.visit(node.end_node)
298
+ }
299
+ }
300
+
301
+ visitERBRescueNode(node: Nodes.ERBRescueNode): void {
302
+ this.printERBNode(node)
303
+
304
+ if (node.statements) {
305
+ node.statements.forEach(statement => this.visit(statement))
306
+ }
307
+
308
+ if (node.subsequent) {
309
+ this.visit(node.subsequent)
310
+ }
311
+ }
312
+
313
+ visitERBEnsureNode(node: Nodes.ERBEnsureNode): void {
314
+ this.printERBNode(node)
315
+
316
+ if (node.statements) {
317
+ node.statements.forEach(statement => this.visit(statement))
318
+ }
319
+ }
320
+
321
+ visitERBUnlessNode(node: Nodes.ERBUnlessNode): void {
322
+ this.printERBNode(node)
323
+
324
+ if (node.statements) {
325
+ node.statements.forEach(statement => this.visit(statement))
326
+ }
327
+
328
+ if (node.else_clause) {
329
+ this.visit(node.else_clause)
330
+ }
331
+
332
+ if (node.end_node) {
333
+ this.visit(node.end_node)
334
+ }
335
+ }
336
+
337
+ visitERBYieldNode(node: Nodes.ERBYieldNode): void {
338
+ this.printERBNode(node)
339
+ }
340
+
341
+ visitERBInNode(node: Nodes.ERBInNode): void {
342
+ this.printERBNode(node)
343
+
344
+ if (node.statements) {
345
+ node.statements.forEach(statement => this.visit(statement))
346
+ }
347
+ }
348
+
349
+ visitERBCaseMatchNode(node: Nodes.ERBCaseMatchNode): void {
350
+ this.printERBNode(node)
351
+
352
+ if (node.children) {
353
+ node.children.forEach(child => this.visit(child))
354
+ }
355
+
356
+ if (node.conditions) {
357
+ node.conditions.forEach(condition => this.visit(condition))
358
+ }
359
+
360
+ if (node.else_clause) {
361
+ this.visit(node.else_clause)
362
+ }
363
+
364
+ if (node.end_node) {
365
+ this.visit(node.end_node)
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Print ERB node tags and content
371
+ */
372
+ protected printERBNode(node: Nodes.ERBNode): void {
373
+ if (node.tag_opening) {
374
+ this.write(node.tag_opening.value)
375
+ }
376
+
377
+ if (node.content) {
378
+ this.write(node.content.value)
379
+ }
13
380
 
381
+ if (node.tag_closing) {
382
+ this.write(node.tag_closing.value)
383
+ }
384
+ }
14
385
  }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { IdentityPrinter } from "./identity-printer.js"
2
+ export { ERBToRubyStringPrinter } from "./erb-to-ruby-string-printer.js"
2
3
  export { PrintContext } from "./print-context.js"
3
4
  export { Printer, DEFAULT_PRINT_OPTIONS } from "./printer.js"
4
5
  export type { PrintOptions } from "./printer.js"