@herb-tools/printer 0.6.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/cli.ts ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+
3
+ import dedent from "dedent"
4
+
5
+ import { readFileSync, writeFileSync } from "fs"
6
+ import { resolve } from "path"
7
+ import { glob } from "glob"
8
+
9
+ import { Herb } from "@herb-tools/node-wasm"
10
+ import { IdentityPrinter } from "./index.js"
11
+
12
+ interface CLIOptions {
13
+ input?: string
14
+ output?: string
15
+ verify?: boolean
16
+ stats?: boolean
17
+ help?: boolean
18
+ glob?: boolean
19
+ }
20
+
21
+ export class CLI {
22
+ private parseArgs(args: string[]): CLIOptions {
23
+ const options: CLIOptions = {}
24
+
25
+ for (let i = 2; i < args.length; i++) {
26
+ const arg = args[i]
27
+
28
+ switch (arg) {
29
+ case '-i':
30
+ case '--input':
31
+ options.input = args[++i]
32
+ break
33
+ case '-o':
34
+ case '--output':
35
+ options.output = args[++i]
36
+ break
37
+ case '--verify':
38
+ options.verify = true
39
+ break
40
+ case '--stats':
41
+ options.stats = true
42
+ break
43
+ case '--glob':
44
+ options.glob = true
45
+ break
46
+ case '-h':
47
+ case '--help':
48
+ options.help = true
49
+ break
50
+ default:
51
+ if (!arg.startsWith('-') && !options.input) {
52
+ options.input = arg
53
+ }
54
+ }
55
+ }
56
+
57
+ return options
58
+ }
59
+
60
+ private showHelp() {
61
+ console.log(dedent`
62
+ herb-print - Print HTML+ERB AST back to source code
63
+
64
+ This tool parses HTML+ERB templates and prints them back, preserving the original
65
+ formatting as closely as possible. Useful for testing parser accuracy and as a
66
+ baseline for other transformations.
67
+
68
+ Usage:
69
+ herb-print [options] <input-file-or-pattern>
70
+ herb-print -i <input-file> -o <output-file>
71
+
72
+ Options:
73
+ -i, --input <file> Input file path
74
+ -o, --output <file> Output file path (defaults to stdout)
75
+ --verify Verify that output matches input exactly
76
+ --stats Show parsing and printing statistics
77
+ --glob Treat input as glob pattern (default: **/*.html.erb)
78
+ -h, --help Show this help message
79
+
80
+ Examples:
81
+ # Single file
82
+ herb-print input.html.erb > output.html.erb
83
+ herb-print -i input.html.erb -o output.html.erb --verify
84
+ herb-print input.html.erb --stats
85
+
86
+ # Glob patterns (batch verification)
87
+ herb-print --glob --verify # All .html.erb files
88
+ herb-print "app/views/**/*.html.erb" --glob --verify --stats
89
+ herb-print "*.erb" --glob --verify
90
+ herb-print "/path/to/templates" --glob --verify # Directory (auto-appends /**/*.html.erb)
91
+ herb-print "/path/to/templates/**/*.html.erb" --glob --verify
92
+
93
+ # The --verify flag is useful to test parser fidelity:
94
+ herb-print input.html.erb --verify
95
+ # Checks if parsing and printing results in identical content
96
+ `)
97
+ }
98
+
99
+ async run() {
100
+ const options = this.parseArgs(process.argv)
101
+
102
+ if (options.help || (!options.input && !options.glob)) {
103
+ this.showHelp()
104
+ process.exit(0)
105
+ }
106
+
107
+ try {
108
+ await Herb.load()
109
+
110
+ if (options.glob) {
111
+ const pattern = options.input || "**/*.html.erb"
112
+ const files = await glob(pattern)
113
+
114
+ if (files.length === 0) {
115
+ console.error(`No files found matching pattern: ${pattern}`)
116
+ process.exit(1)
117
+ }
118
+
119
+ let totalFiles = 0
120
+ let failedFiles = 0
121
+ let verificationFailures = 0
122
+ let totalBytes = 0
123
+
124
+ console.log(`Processing ${files.length} files...\n`)
125
+
126
+ for (const file of files) {
127
+ try {
128
+ const input = readFileSync(file, 'utf-8')
129
+ const parseResult = Herb.parse(input, { track_whitespace: true })
130
+
131
+ if (parseResult.errors.length > 0) {
132
+ console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mFailed\x1b[0m to parse`)
133
+ failedFiles++
134
+ continue
135
+ }
136
+
137
+ const printer = new IdentityPrinter()
138
+ const output = printer.print(parseResult.value)
139
+
140
+ totalFiles++
141
+ totalBytes += input.length
142
+
143
+ if (options.verify) {
144
+ if (input === output) {
145
+ console.log(`\x1b[32m✓\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[32mPerfect match\x1b[0m`)
146
+ } else {
147
+ console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mVerification failed\x1b[0m - differences detected`)
148
+ verificationFailures++
149
+ }
150
+ } else {
151
+ console.log(`\x1b[32m✓\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[32mProcessed\x1b[0m`)
152
+ }
153
+
154
+ } catch (error) {
155
+ console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mError\x1b[0m - ${error}`)
156
+ failedFiles++
157
+ }
158
+ }
159
+
160
+ console.log(`\nSummary:`)
161
+ console.log(` Files processed: ${totalFiles}`)
162
+ console.log(` Files failed: ${failedFiles}`)
163
+
164
+ if (options.verify) {
165
+ console.log(` Verifications: ${totalFiles - verificationFailures} passed, ${verificationFailures} failed`)
166
+ }
167
+
168
+ if (options.stats) {
169
+ console.log(` Total bytes: ${totalBytes}`)
170
+ }
171
+
172
+ process.exit(failedFiles > 0 || verificationFailures > 0 ? 1 : 0)
173
+
174
+ } else {
175
+ const inputPath = resolve(options.input!)
176
+ const input = readFileSync(inputPath, 'utf-8')
177
+
178
+ const parseResult = Herb.parse(input, { track_whitespace: true })
179
+
180
+ if (parseResult.errors.length > 0) {
181
+ console.error('Parse errors:', parseResult.errors.map(e => e.message).join(', '))
182
+ process.exit(1)
183
+ }
184
+
185
+ const printer = new IdentityPrinter()
186
+ const output = printer.print(parseResult.value)
187
+
188
+ if (options.output) {
189
+ const outputPath = resolve(options.output)
190
+ writeFileSync(outputPath, output, 'utf-8')
191
+
192
+ console.log(`Output written to: ${outputPath}`)
193
+ } else {
194
+ console.log(output)
195
+ }
196
+
197
+ if (options.verify) {
198
+ if (input === output) {
199
+ console.error('\x1b[32m✓ Verification passed\x1b[0m - output matches input exactly')
200
+ } else {
201
+ console.error('\x1b[31m✗ Verification failed\x1b[0m - output differs from input')
202
+ process.exit(1)
203
+ }
204
+ }
205
+
206
+ if (options.stats) {
207
+ const errors = parseResult.errors?.length || 0
208
+ const warnings = parseResult.warnings?.length || 0
209
+
210
+ console.error(dedent`
211
+ Printing Statistics:
212
+ Input size: ${input.length} bytes
213
+ Output size: ${output.length} bytes
214
+ Parse errors: ${errors}
215
+ Parse warnings: ${warnings}
216
+ Round-trip: ${input === output ? 'Perfect' : 'Differences detected'}
217
+ `)
218
+ }
219
+ }
220
+
221
+ } catch (error) {
222
+ console.error('Error:', error instanceof Error ? error.message : error)
223
+ process.exit(1)
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { CLI } from "./cli.js"
4
+
5
+ const cli = new CLI()
6
+ cli.run()
@@ -0,0 +1,14 @@
1
+ import { Printer } from "./printer.js"
2
+
3
+ /**
4
+ * IdentityPrinter - Provides lossless reconstruction of the original source
5
+ *
6
+ * This printer aims to reconstruct the original input as faithfully as possible,
7
+ * preserving all whitespace, formatting, and structure. It's useful for:
8
+ * - Testing parser accuracy (input should equal output)
9
+ * - Baseline printing before applying transformations
10
+ * - Verifying AST round-trip fidelity
11
+ */
12
+ export class IdentityPrinter extends Printer {
13
+
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { IdentityPrinter } from "./identity-printer.js"
2
+ export { PrintContext } from "./print-context.js"
3
+ export { Printer, DEFAULT_PRINT_OPTIONS } from "./printer.js"
4
+ export type { PrintOptions } from "./printer.js"
@@ -0,0 +1,104 @@
1
+ export class PrintContext {
2
+ private output: string = ""
3
+ private indentLevel: number = 0
4
+ private currentColumn: number = 0
5
+ private preserveStack: string[] = []
6
+
7
+ /**
8
+ * Write text to the output
9
+ */
10
+ write(text: string): void {
11
+ this.output += text
12
+ this.currentColumn += text.length
13
+ }
14
+
15
+ /**
16
+ * Write text and update column tracking for newlines
17
+ */
18
+ writeWithColumnTracking(text: string): void {
19
+ this.output += text
20
+
21
+ const lines = text.split('\n')
22
+
23
+ if (lines.length > 1) {
24
+ this.currentColumn = lines[lines.length - 1].length
25
+ } else {
26
+ this.currentColumn += text.length
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Increase indentation level
32
+ */
33
+ indent(): void {
34
+ this.indentLevel++
35
+ }
36
+
37
+ /**
38
+ * Decrease indentation level
39
+ */
40
+ dedent(): void {
41
+ if (this.indentLevel > 0) {
42
+ this.indentLevel--
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Enter a tag that may preserve whitespace
48
+ */
49
+ enterTag(tagName: string): void {
50
+ this.preserveStack.push(tagName.toLowerCase())
51
+ }
52
+
53
+ /**
54
+ * Exit the current tag
55
+ */
56
+ exitTag(): void {
57
+ this.preserveStack.pop()
58
+ }
59
+
60
+ /**
61
+ * Check if we're at the start of a line
62
+ */
63
+ isAtStartOfLine(): boolean {
64
+ return this.currentColumn === 0
65
+ }
66
+
67
+ /**
68
+ * Get current indentation level
69
+ */
70
+ getCurrentIndentLevel(): number {
71
+ return this.indentLevel
72
+ }
73
+
74
+ /**
75
+ * Get current column position
76
+ */
77
+ getCurrentColumn(): number {
78
+ return this.currentColumn
79
+ }
80
+
81
+ /**
82
+ * Get the current tag stack (for debugging)
83
+ */
84
+ getTagStack(): string[] {
85
+ return [...this.preserveStack]
86
+ }
87
+
88
+ /**
89
+ * Get the complete output string
90
+ */
91
+ getOutput(): string {
92
+ return this.output
93
+ }
94
+
95
+ /**
96
+ * Reset the context for reuse
97
+ */
98
+ reset(): void {
99
+ this.output = ""
100
+ this.indentLevel = 0
101
+ this.currentColumn = 0
102
+ this.preserveStack = []
103
+ }
104
+ }