@herb-tools/formatter 0.7.5 → 0.8.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.
package/src/cli.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import dedent from "dedent"
2
2
  import { readFileSync, writeFileSync, statSync } from "fs"
3
3
  import { glob } from "glob"
4
- import { join, resolve } from "path"
4
+ import { resolve, relative } from "path"
5
5
 
6
6
  import { Herb } from "@herb-tools/node-wasm"
7
+ import { Config, addHerbExtensionRecommendation, getExtensionsJsonRelativePath } from "@herb-tools/config"
8
+ import { colorize } from "@herb-tools/highlighter"
9
+
7
10
  import { Formatter } from "./formatter.js"
11
+ import { ASTRewriter, StringRewriter, CustomRewriterLoader, builtinRewriters, isASTRewriterClass, isStringRewriterClass } from "@herb-tools/rewriter/loader"
8
12
  import { parseArgs } from "util"
9
- import { resolveFormatOptions } from "./options.js"
10
13
 
11
14
  import { name, version, dependencies } from "../package.json"
12
15
 
@@ -16,32 +19,39 @@ const pluralize = (count: number, singular: string, plural: string = singular +
16
19
 
17
20
  export class CLI {
18
21
  private usage = dedent`
19
- Usage: herb-format [file|directory|glob-pattern] [options]
22
+ Usage: herb-format [files|directories|glob-patterns...] [options]
20
23
 
21
24
  Arguments:
22
- file|directory|glob-pattern File to format, directory to format all **/*.html.erb files within,
23
- glob pattern to match files, or '-' for stdin (omit to format all **/*.html.erb files in current directory)
25
+ files|directories|glob-patterns Files, directories, or glob patterns to format, or '-' for stdin
26
+ Multiple arguments are supported (e.g., herb-format file1.erb file2.erb dir/)
27
+ Omit to format all configured files in current directory
24
28
 
25
29
  Options:
26
30
  -c, --check check if files are formatted without modifying them
27
31
  -h, --help show help
28
32
  -v, --version show version
33
+ --init create a .herb.yml configuration file in the current directory
34
+ --config-file <path> explicitly specify path to .herb.yml config file
35
+ --force force formatting even if disabled in .herb.yml
29
36
  --indent-width <number> number of spaces per indentation level (default: 2)
30
37
  --max-line-length <number> maximum line length before wrapping (default: 80)
31
38
 
32
39
  Examples:
33
- herb-format # Format all **/*.html.erb files in current directory
34
- herb-format index.html.erb # Format and write single file
35
- herb-format templates/index.html.erb # Format and write single file
36
- herb-format templates/ # Format and **/*.html.erb within the given directory
37
- herb-format "templates/**/*.html.erb" # Format all .html.erb files in templates directory using glob pattern
38
- herb-format "**/*.html.erb" # Format all .html.erb files using glob pattern
39
- herb-format "**/*.xml.erb" # Format all .xml.erb files using glob pattern
40
- herb-format --check # Check if all **/*.html.erb files are formatted
41
- herb-format --check templates/ # Check if all **/*.html.erb files in templates/ are formatted
42
- herb-format --indent-width 4 # Format with 4-space indentation
43
- herb-format --max-line-length 100 # Format with 100-character line limit
44
- cat template.html.erb | herb-format # Format from stdin to stdout
40
+ herb-format # Format all configured files in current directory
41
+ herb-format index.html.erb # Format and write single file
42
+ herb-format templates/index.html.erb # Format and write single file
43
+ herb-format templates/ # Format all configured files within the given directory
44
+ herb-format "templates/**/*.html.erb" # Format all \`**/*.html.erb\` files in the templates/ directory
45
+ herb-format "**/*.html.erb" # Format all \`*.html.erb\` files using glob pattern
46
+ herb-format "**/*.xml.erb" # Format all \`*.xml.erb\` files using glob pattern
47
+
48
+ herb-format --check # Check if all configured files are formatted
49
+ herb-format --check templates/ # Check if all configured files in templates/ are formatted
50
+
51
+ herb-format --force # Format even if disabled in project config
52
+ herb-format --indent-width 4 # Format with 4-space indentation
53
+ herb-format --max-line-length 100 # Format with 100-character line limit
54
+ cat template.html.erb | herb-format # Format from stdin to stdout
45
55
  `
46
56
 
47
57
  private parseArguments() {
@@ -49,8 +59,11 @@ export class CLI {
49
59
  args: process.argv.slice(2),
50
60
  options: {
51
61
  help: { type: "boolean", short: "h" },
62
+ force: { type: "boolean" },
52
63
  version: { type: "boolean", short: "v" },
53
64
  check: { type: "boolean", short: "c" },
65
+ init: { type: "boolean" },
66
+ "config-file": { type: "string" },
54
67
  "indent-width": { type: "string" },
55
68
  "max-line-length": { type: "string" }
56
69
  },
@@ -92,13 +105,16 @@ export class CLI {
92
105
  positionals,
93
106
  isCheckMode: values.check,
94
107
  isVersionMode: values.version,
108
+ isForceMode: values.force,
109
+ isInitMode: values.init,
110
+ configFile: values["config-file"],
95
111
  indentWidth,
96
112
  maxLineLength
97
113
  }
98
114
  }
99
115
 
100
116
  async run() {
101
- const { positionals, isCheckMode, isVersionMode, indentWidth, maxLineLength } = this.parseArguments()
117
+ const { positionals, isCheckMode, isVersionMode, isForceMode, isInitMode, configFile, indentWidth, maxLineLength } = this.parseArguments()
102
118
 
103
119
  try {
104
120
  await Herb.load()
@@ -112,17 +128,209 @@ export class CLI {
112
128
  process.exit(0)
113
129
  }
114
130
 
131
+ if (positionals.includes('-') && positionals.length > 1) {
132
+ console.error("Error: Cannot mix stdin ('-') with file arguments")
133
+ process.exit(1)
134
+ }
135
+
136
+ const file = positionals[0]
137
+ const startPath = file || process.cwd()
138
+
139
+ if (isInitMode) {
140
+ const configPath = configFile || startPath
141
+
142
+ if (Config.exists(configPath)) {
143
+ const fullPath = configFile || Config.configPathFromProjectPath(startPath)
144
+ console.log(`\n✗ Configuration file already exists at ${fullPath}`)
145
+ console.log(` Use --config-file to specify a different location.\n`)
146
+ process.exit(1)
147
+ }
148
+
149
+ const config = await Config.loadForCLI(configPath, version, true)
150
+
151
+ await Config.mutateConfigFile(config.path, {
152
+ formatter: {
153
+ enabled: true
154
+ }
155
+ })
156
+
157
+ const projectPath = configFile ? resolve(configFile) : startPath
158
+ const projectDir = statSync(projectPath).isDirectory() ? projectPath : resolve(projectPath, '..')
159
+ const extensionAdded = addHerbExtensionRecommendation(projectDir)
160
+
161
+ console.log(`\n✓ Configuration initialized at ${config.path}`)
162
+
163
+ if (extensionAdded) {
164
+ console.log(`✓ VSCode extension recommended in ${getExtensionsJsonRelativePath()}`)
165
+ }
166
+
167
+ console.log(` Formatter is enabled by default.`)
168
+ console.log(` Edit this file to customize linter and formatter settings.\n`)
169
+
170
+ process.exit(0)
171
+ }
172
+
173
+ const config = await Config.loadForCLI(configFile || startPath, version)
174
+ const formatterConfig = config.formatter || {}
175
+
176
+ if (formatterConfig.enabled === false && !isForceMode) {
177
+ console.log("Formatter is disabled in .herb.yml configuration.")
178
+ console.log("To enable formatting, set formatter.enabled: true in .herb.yml")
179
+ console.log("Or use --force to format anyway.")
180
+
181
+ process.exit(0)
182
+ }
183
+
184
+ if (isForceMode && formatterConfig.enabled === false) {
185
+ console.error("⚠️ Forcing formatter run (disabled in .herb.yml)")
186
+ console.error()
187
+ }
188
+
115
189
  console.error("⚠️ 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")
116
190
  console.error()
117
191
 
118
- const formatOptions = resolveFormatOptions({
119
- indentWidth,
120
- maxLineLength
121
- })
192
+ if (indentWidth !== undefined) {
193
+ formatterConfig.indentWidth = indentWidth
194
+ }
122
195
 
123
- const formatter = new Formatter(Herb, formatOptions)
196
+ if (maxLineLength !== undefined) {
197
+ formatterConfig.maxLineLength = maxLineLength
198
+ }
124
199
 
125
- const file = positionals[0]
200
+ let preRewriters: ASTRewriter[] = []
201
+ let postRewriters: StringRewriter[] = []
202
+ const rewriterNames = { pre: formatterConfig.rewriter?.pre || [], post: formatterConfig.rewriter?.post || [] }
203
+
204
+ if (formatterConfig.rewriter && (rewriterNames.pre.length > 0 || rewriterNames.post.length > 0)) {
205
+ const baseDir = config.projectPath || process.cwd()
206
+ const warnings: string[] = []
207
+ const allRewriterClasses: any[] = []
208
+
209
+ allRewriterClasses.push(...builtinRewriters)
210
+
211
+ const loader = new CustomRewriterLoader({ baseDir })
212
+ const { rewriters: customRewriters, rewriterInfo, duplicateWarnings } = await loader.loadRewritersWithInfo()
213
+
214
+ if (customRewriters.length > 0) {
215
+ console.error(colorize(`\nLoaded ${customRewriters.length} custom ${pluralize(customRewriters.length, 'rewriter')}:`, "green"))
216
+
217
+ for (const { name, path } of rewriterInfo) {
218
+ const relativePath = config.projectPath ? path.replace(config.projectPath + '/', '') : path
219
+
220
+ console.error(colorize(` • ${name}`, "cyan") + colorize(` (${relativePath})`, "dim"))
221
+ }
222
+
223
+ console.error()
224
+ }
225
+
226
+ allRewriterClasses.push(...customRewriters)
227
+ warnings.push(...duplicateWarnings)
228
+
229
+ const rewriterMap = new Map<string, any>()
230
+ for (const RewriterClass of allRewriterClasses) {
231
+ const instance = new RewriterClass()
232
+
233
+ if (rewriterMap.has(instance.name)) {
234
+ warnings.push(`Rewriter "${instance.name}" is defined multiple times. Using the last definition.`)
235
+ }
236
+
237
+ rewriterMap.set(instance.name, RewriterClass)
238
+ }
239
+
240
+ for (const name of rewriterNames.pre) {
241
+ const RewriterClass = rewriterMap.get(name)
242
+
243
+ if (!RewriterClass) {
244
+ warnings.push(`Pre-format rewriter "${name}" not found. Skipping.`)
245
+ continue
246
+ }
247
+
248
+ if (!isASTRewriterClass(RewriterClass)) {
249
+ warnings.push(`Rewriter "${name}" is not a pre-format rewriter. Skipping.`)
250
+
251
+ continue
252
+ }
253
+
254
+ const instance = new RewriterClass()
255
+ try {
256
+ await instance.initialize({ baseDir })
257
+ preRewriters.push(instance)
258
+ } catch (error) {
259
+ warnings.push(`Failed to initialize pre-format rewriter "${name}": ${error}`)
260
+ }
261
+ }
262
+
263
+ for (const name of rewriterNames.post) {
264
+ const RewriterClass = rewriterMap.get(name)
265
+
266
+ if (!RewriterClass) {
267
+ warnings.push(`Post-format rewriter "${name}" not found. Skipping.`)
268
+
269
+ continue
270
+ }
271
+
272
+ if (!isStringRewriterClass(RewriterClass)) {
273
+ warnings.push(`Rewriter "${name}" is not a post-format rewriter. Skipping.`)
274
+
275
+ continue
276
+ }
277
+
278
+ const instance = new RewriterClass()
279
+
280
+ try {
281
+ await instance.initialize({ baseDir })
282
+
283
+ postRewriters.push(instance)
284
+ } catch (error) {
285
+ warnings.push(`Failed to initialize post-format rewriter "${name}": ${error}`)
286
+ }
287
+ }
288
+
289
+ if (preRewriters.length > 0 || postRewriters.length > 0) {
290
+ const customRewriterPaths = new Map(rewriterInfo.map(r => [r.name, r.path]))
291
+
292
+ if (preRewriters.length > 0) {
293
+ console.error(colorize(`\nUsing ${preRewriters.length} pre-format ${pluralize(preRewriters.length, 'rewriter')}:`, "green"))
294
+
295
+ for (const rewriter of preRewriters) {
296
+ const customPath = customRewriterPaths.get(rewriter.name)
297
+
298
+ if (customPath) {
299
+ const relativePath = config.projectPath ? customPath.replace(config.projectPath + '/', '') : customPath
300
+ console.error(colorize(` • ${rewriter.name}`, "cyan") + colorize(` (${relativePath})`, "dim"))
301
+ } else {
302
+ console.error(colorize(` • ${rewriter.name}`, "cyan") + colorize(` (built-in)`, "dim"))
303
+ }
304
+ }
305
+
306
+ console.error()
307
+ }
308
+
309
+ if (postRewriters.length > 0) {
310
+ console.error(colorize(`\nUsing ${postRewriters.length} post-format ${pluralize(postRewriters.length, 'rewriter')}:`, "green"))
311
+
312
+ for (const rewriter of postRewriters) {
313
+ const customPath = customRewriterPaths.get(rewriter.name)
314
+
315
+ if (customPath) {
316
+ const relativePath = config.projectPath ? customPath.replace(config.projectPath + '/', '') : customPath
317
+ console.error(colorize(` • ${rewriter.name}`, "cyan") + colorize(` (${relativePath})`, "dim"))
318
+ } else {
319
+ console.error(colorize(` • ${rewriter.name}`, "cyan") + colorize(` (built-in)`, "dim"))
320
+ }
321
+ }
322
+
323
+ console.error()
324
+ }
325
+ }
326
+
327
+ if (warnings.length > 0) {
328
+ warnings.forEach(warning => console.error(`⚠️ ${warning}`))
329
+ console.error()
330
+ }
331
+ }
332
+
333
+ const formatter = Formatter.from(Herb, config, { preRewriters, postRewriters })
126
334
 
127
335
  if (!file && !process.stdin.isTTY) {
128
336
  if (isCheckMode) {
@@ -148,107 +356,90 @@ export class CLI {
148
356
  const output = result.endsWith('\n') ? result : result + '\n'
149
357
 
150
358
  process.stdout.write(output)
151
- } else if (file) {
152
- let isDirectory = false
153
- let isFile = false
154
- let pattern = file
155
-
156
- try {
157
- const stats = statSync(file)
158
- isDirectory = stats.isDirectory()
159
- isFile = stats.isFile()
160
- } catch {
161
- // Not a file/directory, treat as glob pattern
162
- }
359
+ } else if (positionals.length > 0) {
360
+ const allFiles: string[] = []
361
+
362
+ let hasErrors = false
363
+
364
+ for (const pattern of positionals) {
365
+ try {
366
+ const files = await this.resolvePatternToFiles(pattern, config, isForceMode)
367
+
368
+ if (files.length === 0) {
369
+ const isLikelySpecificFile = !pattern.includes('*') && !pattern.includes('?') &&
370
+ !pattern.includes('[') && !pattern.includes('{')
163
371
 
164
- if (isDirectory) {
165
- pattern = join(file, "**/*.html.erb")
166
- } else if (isFile) {
167
- const source = readFileSync(file, "utf-8")
168
- const result = formatter.format(source)
169
- const output = result.endsWith('\n') ? result : result + '\n'
170
-
171
- if (output !== source) {
172
- if (isCheckMode) {
173
- console.log(`File is not formatted: ${file}`)
174
- process.exit(1)
175
- } else {
176
- writeFileSync(file, output, "utf-8")
177
- console.log(`Formatted: ${file}`)
372
+ if (isLikelySpecificFile) {
373
+ continue
374
+ } else {
375
+ console.log(`No files found matching pattern: ${pattern}`)
376
+ process.exit(0)
377
+ }
178
378
  }
179
- } else if (isCheckMode) {
180
- console.log(`File is properly formatted: ${file}`)
181
- }
182
379
 
183
- process.exit(0)
380
+ allFiles.push(...files)
381
+ } catch (error: any) {
382
+ console.error(`Error: ${error.message}`)
383
+ hasErrors = true
384
+ break
385
+ }
184
386
  }
185
387
 
186
- try {
187
- const files = await glob(pattern)
388
+ if (hasErrors) {
389
+ process.exit(1)
390
+ }
188
391
 
189
- if (files.length === 0) {
190
- try {
191
- statSync(file)
192
- } catch {
193
- if (!file.includes('*') && !file.includes('?') && !file.includes('[') && !file.includes('{')) {
194
- console.error(`Error: Cannot access '${file}': ENOENT: no such file or directory`)
392
+ const files = [...new Set(allFiles)]
195
393
 
196
- process.exit(1)
197
- }
198
- }
394
+ if (files.length === 0) {
395
+ console.log(`No files found matching patterns: ${positionals.join(', ')}`)
396
+ process.exit(0)
397
+ }
199
398
 
200
- console.log(`No files found matching pattern: ${resolve(pattern)}`)
399
+ let formattedCount = 0
400
+ let unformattedFiles: string[] = []
201
401
 
202
- process.exit(0)
203
- }
402
+ for (const filePath of files) {
403
+ const displayPath = relative(process.cwd(), filePath)
204
404
 
205
- let formattedCount = 0
206
- let unformattedFiles: string[] = []
207
-
208
- for (const filePath of files) {
209
- try {
210
- const source = readFileSync(filePath, "utf-8")
211
- const result = formatter.format(source)
212
- const output = result.endsWith('\n') ? result : result + '\n'
213
-
214
- if (output !== source) {
215
- if (isCheckMode) {
216
- unformattedFiles.push(filePath)
217
- } else {
218
- writeFileSync(filePath, output, "utf-8")
219
- console.log(`Formatted: ${filePath}`)
220
- }
221
-
222
- formattedCount++
223
- }
224
- } catch (error) {
225
- console.error(`Error formatting ${filePath}:`, error)
226
- }
227
- }
405
+ try {
406
+ const source = readFileSync(filePath, "utf-8")
407
+ const result = formatter.format(source)
408
+ const output = result.endsWith('\n') ? result : result + '\n'
228
409
 
229
- if (isCheckMode) {
230
- if (unformattedFiles.length > 0) {
231
- console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`)
232
- unformattedFiles.forEach(file => console.log(` ${file}`))
233
- console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`)
234
- process.exit(1)
410
+ if (output !== source) {
411
+ if (isCheckMode) {
412
+ unformattedFiles.push(displayPath)
235
413
  } else {
236
- console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`)
414
+ writeFileSync(filePath, output, "utf-8")
415
+ console.log(`Formatted: ${displayPath}`)
237
416
  }
238
- } else {
239
- console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`)
417
+ formattedCount++
240
418
  }
419
+ } catch (error) {
420
+ console.error(`Error formatting ${displayPath}:`, error)
421
+ }
422
+ }
241
423
 
242
- } catch (error) {
243
- console.error(`Error: Cannot access '${file}':`, error)
244
-
245
- process.exit(1)
424
+ if (isCheckMode) {
425
+ if (unformattedFiles.length > 0) {
426
+ console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`)
427
+ unformattedFiles.forEach(file => console.log(` ${file}`))
428
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`)
429
+ process.exit(1)
430
+ } else {
431
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`)
432
+ }
433
+ } else {
434
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`)
246
435
  }
436
+
437
+ process.exit(0)
247
438
  } else {
248
- const files = await glob("**/*.html.erb")
439
+ const files = await config.findFilesForTool('formatter', process.cwd())
249
440
 
250
441
  if (files.length === 0) {
251
- console.log(`No files found matching pattern: ${resolve("**/*.html.erb")}`)
442
+ console.log(`No files found matching configured patterns`)
252
443
 
253
444
  process.exit(0)
254
445
  }
@@ -257,6 +448,8 @@ export class CLI {
257
448
  let unformattedFiles: string[] = []
258
449
 
259
450
  for (const filePath of files) {
451
+ const displayPath = relative(process.cwd(), filePath)
452
+
260
453
  try {
261
454
  const source = readFileSync(filePath, "utf-8")
262
455
  const result = formatter.format(source)
@@ -264,15 +457,15 @@ export class CLI {
264
457
 
265
458
  if (output !== source) {
266
459
  if (isCheckMode) {
267
- unformattedFiles.push(filePath)
460
+ unformattedFiles.push(displayPath)
268
461
  } else {
269
462
  writeFileSync(filePath, output, "utf-8")
270
- console.log(`Formatted: ${filePath}`)
463
+ console.log(`Formatted: ${displayPath}`)
271
464
  }
272
465
  formattedCount++
273
466
  }
274
467
  } catch (error) {
275
- console.error(`Error formatting ${filePath}:`, error)
468
+ console.error(`Error formatting ${displayPath}:`, error)
276
469
  }
277
470
  }
278
471
 
@@ -306,4 +499,57 @@ export class CLI {
306
499
 
307
500
  return Buffer.concat(chunks).toString("utf8")
308
501
  }
502
+
503
+ private async resolvePatternToFiles(pattern: string, config: Config, isForceMode: boolean | undefined): Promise<string[]> {
504
+ let isDirectory = false
505
+ let isFile = false
506
+
507
+ try {
508
+ const stats = statSync(pattern)
509
+ isDirectory = stats.isDirectory()
510
+ isFile = stats.isFile()
511
+ } catch {
512
+ // Not a file/directory, treat as glob pattern
513
+ }
514
+
515
+ const filesConfig = config.getFilesConfigForTool('formatter')
516
+
517
+ if (isDirectory) {
518
+ const files = await config.findFilesForTool('formatter', resolve(pattern))
519
+ return files
520
+ } else if (isFile) {
521
+ const testFiles = await glob(pattern, {
522
+ cwd: process.cwd(),
523
+ ignore: filesConfig.exclude || []
524
+ })
525
+
526
+ if (testFiles.length === 0) {
527
+ if (!isForceMode) {
528
+ console.error(`⚠️ File ${pattern} is excluded by configuration patterns.`)
529
+ console.error(` Use --force to format it anyway.\n`)
530
+ process.exit(0)
531
+ } else {
532
+ console.error(`⚠️ Forcing formatter on excluded file: ${pattern}`)
533
+ console.error()
534
+ return [pattern]
535
+ }
536
+ }
537
+
538
+ return [pattern]
539
+ }
540
+
541
+ const files = await glob(pattern, { ignore: filesConfig.exclude || [] })
542
+
543
+ if (files.length === 0) {
544
+ try {
545
+ statSync(pattern)
546
+ } catch {
547
+ if (!pattern.includes('*') && !pattern.includes('?') && !pattern.includes('[') && !pattern.includes('{')) {
548
+ throw new Error(`Cannot access '${pattern}': ENOENT: no such file or directory`)
549
+ }
550
+ }
551
+ }
552
+
553
+ return files
554
+ }
309
555
  }