forked from marcoroth/herb
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.ts
More file actions
309 lines (253 loc) · 10.7 KB
/
cli.ts
File metadata and controls
309 lines (253 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
import dedent from "dedent"
import { readFileSync, writeFileSync, statSync } from "fs"
import { glob } from "glob"
import { join, resolve } from "path"
import { Herb } from "@herb-tools/node-wasm"
import { Formatter } from "./formatter.js"
import { parseArgs } from "util"
import { resolveFormatOptions } from "./options.js"
import { name, version, dependencies } from "../package.json"
const pluralize = (count: number, singular: string, plural: string = singular + 's'): string => {
return count === 1 ? singular : plural
}
export class CLI {
private usage = dedent`
Usage: herb-format [file|directory|glob-pattern] [options]
Arguments:
file|directory|glob-pattern File to format, directory to format all **/*.html.erb files within,
glob pattern to match files, or '-' for stdin (omit to format all **/*.html.erb files in current directory)
Options:
-c, --check check if files are formatted without modifying them
-h, --help show help
-v, --version show version
--indent-width <number> number of spaces per indentation level (default: 2)
--max-line-length <number> maximum line length before wrapping (default: 80)
Examples:
herb-format # Format all **/*.html.erb files in current directory
herb-format index.html.erb # Format and write single file
herb-format templates/index.html.erb # Format and write single file
herb-format templates/ # Format and **/*.html.erb within the given directory
herb-format "templates/**/*.html.erb" # Format all .html.erb files in templates directory using glob pattern
herb-format "**/*.html.erb" # Format all .html.erb files using glob pattern
herb-format "**/*.xml.erb" # Format all .xml.erb files using glob pattern
herb-format --check # Check if all **/*.html.erb files are formatted
herb-format --check templates/ # Check if all **/*.html.erb files in templates/ are formatted
herb-format --indent-width 4 # Format with 4-space indentation
herb-format --max-line-length 100 # Format with 100-character line limit
cat template.html.erb | herb-format # Format from stdin to stdout
`
private parseArguments() {
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
check: { type: "boolean", short: "c" },
"indent-width": { type: "string" },
"max-line-length": { type: "string" }
},
allowPositionals: true
})
if (values.help) {
console.log(this.usage)
process.exit(0)
}
let indentWidth: number | undefined
if (values["indent-width"]) {
const parsed = parseInt(values["indent-width"], 10)
if (isNaN(parsed) || parsed < 1) {
console.error(
`Invalid indent-width: ${values["indent-width"]}. Must be a positive integer.`,
)
process.exit(1)
}
indentWidth = parsed
}
let maxLineLength: number | undefined
if (values["max-line-length"]) {
const parsed = parseInt(values["max-line-length"], 10)
if (isNaN(parsed) || parsed < 1) {
console.error(
`Invalid max-line-length: ${values["max-line-length"]}. Must be a positive integer.`,
)
process.exit(1)
}
maxLineLength = parsed
}
return {
positionals,
isCheckMode: values.check,
isVersionMode: values.version,
indentWidth,
maxLineLength
}
}
async run() {
const { positionals, isCheckMode, isVersionMode, indentWidth, maxLineLength } = this.parseArguments()
try {
await Herb.load()
if (isVersionMode) {
console.log("Versions:")
console.log(` ${name}@${version}`)
console.log(` @herb-tools/printer@${dependencies['@herb-tools/printer']}`)
console.log(` ${Herb.version}`.split(", ").join("\n "))
process.exit(0)
}
console.log("⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md")
console.log()
const formatOptions = resolveFormatOptions({
indentWidth,
maxLineLength
})
const formatter = new Formatter(Herb, formatOptions)
const file = positionals[0]
if (!file && !process.stdin.isTTY) {
if (isCheckMode) {
console.error("Error: --check mode is not supported with stdin")
process.exit(1)
}
const source = await this.readStdin()
const result = formatter.format(source)
const output = result.endsWith('\n') ? result : result + '\n'
process.stdout.write(output)
} else if (file === "-") {
if (isCheckMode) {
console.error("Error: --check mode is not supported with stdin")
process.exit(1)
}
const source = await this.readStdin()
const result = formatter.format(source)
const output = result.endsWith('\n') ? result : result + '\n'
process.stdout.write(output)
} else if (file) {
let isDirectory = false
let isFile = false
let pattern = file
try {
const stats = statSync(file)
isDirectory = stats.isDirectory()
isFile = stats.isFile()
} catch {
// Not a file/directory, treat as glob pattern
}
if (isDirectory) {
pattern = join(file, "**/*.html.erb")
} else if (isFile) {
const source = readFileSync(file, "utf-8")
const result = formatter.format(source)
const output = result.endsWith('\n') ? result : result + '\n'
if (output !== source) {
if (isCheckMode) {
console.log(`File is not formatted: ${file}`)
process.exit(1)
} else {
writeFileSync(file, output, "utf-8")
console.log(`Formatted: ${file}`)
}
} else if (isCheckMode) {
console.log(`File is properly formatted: ${file}`)
}
process.exit(0)
}
try {
const files = await glob(pattern)
if (files.length === 0) {
try {
statSync(file)
} catch {
if (!file.includes('*') && !file.includes('?') && !file.includes('[') && !file.includes('{')) {
console.error(`Error: Cannot access '${file}': ENOENT: no such file or directory`)
process.exit(1)
}
}
console.log(`No files found matching pattern: ${resolve(pattern)}`)
process.exit(0)
}
let formattedCount = 0
let unformattedFiles: string[] = []
for (const filePath of files) {
try {
const source = readFileSync(filePath, "utf-8")
const result = formatter.format(source)
const output = result.endsWith('\n') ? result : result + '\n'
if (output !== source) {
if (isCheckMode) {
unformattedFiles.push(filePath)
} else {
writeFileSync(filePath, output, "utf-8")
console.log(`Formatted: ${filePath}`)
}
formattedCount++
}
} catch (error) {
console.error(`Error formatting ${filePath}:`, error)
}
}
if (isCheckMode) {
if (unformattedFiles.length > 0) {
console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`)
unformattedFiles.forEach(file => console.log(` ${file}`))
console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`)
process.exit(1)
} else {
console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`)
}
} else {
console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`)
}
} catch (error) {
console.error(`Error: Cannot access '${file}':`, error)
process.exit(1)
}
} else {
const files = await glob("**/*.html.erb")
if (files.length === 0) {
console.log(`No files found matching pattern: ${resolve("**/*.html.erb")}`)
process.exit(0)
}
let formattedCount = 0
let unformattedFiles: string[] = []
for (const filePath of files) {
try {
const source = readFileSync(filePath, "utf-8")
const result = formatter.format(source)
const output = result.endsWith('\n') ? result : result + '\n'
if (output !== source) {
if (isCheckMode) {
unformattedFiles.push(filePath)
} else {
writeFileSync(filePath, output, "utf-8")
console.log(`Formatted: ${filePath}`)
}
formattedCount++
}
} catch (error) {
console.error(`Error formatting ${filePath}:`, error)
}
}
if (isCheckMode) {
if (unformattedFiles.length > 0) {
console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`)
unformattedFiles.forEach(file => console.log(` ${file}`))
console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`)
process.exit(1)
} else {
console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`)
}
} else {
console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`)
}
}
} catch (error) {
console.error(error)
process.exit(1)
}
}
private async readStdin(): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
}
return Buffer.concat(chunks).toString("utf8")
}
}