@herb-tools/config 0.8.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/config.ts ADDED
@@ -0,0 +1,1105 @@
1
+ import path from "path"
2
+
3
+ import { promises as fs } from "fs"
4
+ import { stringify, parse, parseDocument, isMap } from "yaml"
5
+ import { ZodError } from "zod"
6
+ import { fromZodError } from "zod-validation-error"
7
+ import { minimatch } from "minimatch"
8
+ import { glob } from "glob"
9
+
10
+ import { DiagnosticSeverity } from "@herb-tools/core"
11
+ import { HerbConfigSchema } from "./config-schema.js"
12
+ import { deepMerge } from "./merge.js"
13
+
14
+ import packageJson from "../package.json"
15
+ import configTemplate from "./config-template.yml"
16
+
17
+ const DEFAULT_VERSION = packageJson.version
18
+
19
+ export interface ConfigValidationError {
20
+ message: string
21
+ path: (string | number | symbol)[]
22
+ code: string
23
+ line?: number
24
+ column?: number
25
+ severity?: 'error' | 'warning'
26
+ }
27
+
28
+ export type FilesConfig = {
29
+ include?: string[]
30
+ exclude?: string[]
31
+ }
32
+
33
+ export type RuleConfig = {
34
+ enabled?: boolean
35
+ severity?: DiagnosticSeverity
36
+ autoCorrect?: boolean
37
+ include?: string[]
38
+ only?: string[]
39
+ exclude?: string[]
40
+ }
41
+
42
+ export type LinterConfig = {
43
+ enabled?: boolean
44
+ include?: string[]
45
+ exclude?: string[]
46
+ rules?: Record<string, RuleConfig>
47
+ }
48
+
49
+ export type FormatterConfig = {
50
+ enabled?: boolean
51
+ include?: string[]
52
+ exclude?: string[]
53
+ indentWidth?: number
54
+ maxLineLength?: number
55
+ rewriter?: {
56
+ pre?: string[]
57
+ post?: string[]
58
+ }
59
+ }
60
+
61
+ export type HerbConfigOptions = {
62
+ files?: FilesConfig
63
+ linter?: LinterConfig
64
+ formatter?: FormatterConfig
65
+ }
66
+
67
+ export type HerbConfig = HerbConfigOptions & {
68
+ version: string
69
+ }
70
+
71
+ export type LoadOptions = {
72
+ silent?: boolean
73
+ version?: string
74
+ createIfMissing?: boolean
75
+ exitOnError?: boolean
76
+ }
77
+
78
+ export type FromObjectOptions = {
79
+ projectPath?: string
80
+ version?: string
81
+ }
82
+
83
+ export class Config {
84
+ static configPath = ".herb.yml"
85
+
86
+ private static PROJECT_INDICATORS = [
87
+ '.git',
88
+ 'Gemfile',
89
+ 'package.json',
90
+ 'Rakefile',
91
+ 'README.md',
92
+ '*.gemspec',
93
+ 'config/application.rb'
94
+ ]
95
+
96
+ public readonly path: string
97
+ public config: HerbConfig
98
+
99
+ constructor(projectPath: string, config: HerbConfig) {
100
+ this.path = Config.configPathFromProjectPath(projectPath)
101
+ this.config = config
102
+ }
103
+
104
+ get projectPath(): string {
105
+ return path.dirname(this.path)
106
+ }
107
+
108
+ get version(): string {
109
+ return this.config.version
110
+ }
111
+
112
+ get options(): HerbConfigOptions {
113
+ return {
114
+ files: this.config.files,
115
+ linter: this.config.linter,
116
+ formatter: this.config.formatter
117
+ }
118
+ }
119
+
120
+ get linter() {
121
+ return this.config.linter
122
+ }
123
+
124
+ get formatter() {
125
+ return this.config.formatter
126
+ }
127
+
128
+ public toJSON() {
129
+ return JSON.stringify(this.config, null, " ")
130
+ }
131
+
132
+ /**
133
+ * Check if the linter is enabled.
134
+ * @returns true if linter is enabled (default), false if explicitly disabled
135
+ */
136
+ public get isLinterEnabled(): boolean {
137
+ return this.config.linter?.enabled ?? Config.getDefaultConfig().linter?.enabled ?? true
138
+ }
139
+
140
+ /**
141
+ * Check if the formatter is enabled.
142
+ * @returns true if formatter is enabled (default), false if explicitly disabled
143
+ */
144
+ public get isFormatterEnabled(): boolean {
145
+ return this.config.formatter?.enabled ?? Config.getDefaultConfig().formatter?.enabled ?? true
146
+ }
147
+
148
+ /**
149
+ * Check if a specific rule is disabled.
150
+ * @param ruleName - The name of the rule to check
151
+ * @returns true if the rule is explicitly disabled, false otherwise
152
+ */
153
+ public isRuleDisabled(ruleName: string): boolean {
154
+ return this.config.linter?.rules?.[ruleName]?.enabled === false
155
+ }
156
+
157
+ /**
158
+ * Check if a specific rule is enabled.
159
+ * @param ruleName - The name of the rule to check
160
+ * @returns true if the rule is enabled, false otherwise
161
+ */
162
+ public isRuleEnabled(ruleName: string): boolean {
163
+ return !this.isRuleDisabled(ruleName)
164
+ }
165
+
166
+ /**
167
+ * Get the files configuration for a specific tool.
168
+ * Tool-specific file config takes precedence over top-level config.
169
+ * Include patterns are additive (defaults are already merged in this.config).
170
+ * @param tool - The tool to get files config for ('linter' or 'formatter')
171
+ * @returns The merged files configuration
172
+ */
173
+ public getFilesConfigForTool(tool: 'linter' | 'formatter'): FilesConfig {
174
+ const toolConfig = tool === 'linter' ? this.config.linter : this.config.formatter
175
+ const topLevelFiles = this.config.files || {}
176
+
177
+ const topLevelInclude = topLevelFiles.include || []
178
+ const toolInclude = toolConfig?.include || []
179
+ const include = [...topLevelInclude, ...toolInclude]
180
+
181
+ const exclude = toolConfig?.exclude || topLevelFiles.exclude || []
182
+
183
+ return {
184
+ include,
185
+ exclude
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Get the files configuration for the linter.
191
+ * Linter-specific file config takes precedence over top-level config.
192
+ * @returns The merged files configuration for linter
193
+ */
194
+ public get filesConfigForLinter(): FilesConfig {
195
+ return this.getFilesConfigForTool('linter')
196
+ }
197
+
198
+ /**
199
+ * Get the files configuration for the formatter.
200
+ * Formatter-specific file config takes precedence over top-level config.
201
+ * @returns The merged files configuration for formatter
202
+ */
203
+ public get filesConfigForFormatter(): FilesConfig {
204
+ return this.getFilesConfigForTool('formatter')
205
+ }
206
+
207
+ /**
208
+ * Find files for a specific tool based on its configuration.
209
+ * Uses include patterns from config, applies exclude patterns.
210
+ * @param tool - The tool to find files for ('linter' or 'formatter')
211
+ * @param cwd - The directory to search from (defaults to project path)
212
+ * @returns Promise resolving to array of absolute file paths
213
+ */
214
+ public async findFilesForTool(tool: 'linter' | 'formatter', cwd?: string): Promise<string[]> {
215
+ const searchDir = cwd || path.dirname(this.path)
216
+ const filesConfig = this.getFilesConfigForTool(tool)
217
+
218
+ const patterns = filesConfig.include || []
219
+
220
+ if (patterns.length === 0) {
221
+ return []
222
+ }
223
+
224
+ return await glob(patterns, {
225
+ cwd: searchDir,
226
+ absolute: true,
227
+ nodir: true,
228
+ ignore: filesConfig.exclude || []
229
+ })
230
+ }
231
+
232
+ /**
233
+ * Find files for the linter based on linter configuration.
234
+ * @param cwd - The directory to search from (defaults to project path)
235
+ * @returns Promise resolving to array of absolute file paths
236
+ */
237
+ public async findFilesForLinter(cwd?: string): Promise<string[]> {
238
+ return this.findFilesForTool('linter', cwd)
239
+ }
240
+
241
+ /**
242
+ * Find files for the formatter based on formatter configuration.
243
+ * @param cwd - The directory to search from (defaults to project path)
244
+ * @returns Promise resolving to array of absolute file paths
245
+ */
246
+ public async findFilesForFormatter(cwd?: string): Promise<string[]> {
247
+ return this.findFilesForTool('formatter', cwd)
248
+ }
249
+
250
+ /**
251
+ * Check if a file path is excluded by glob patterns.
252
+ * @param filePath - The file path to check
253
+ * @param excludePatterns - Array of glob patterns to check against
254
+ * @returns true if the path matches any exclude pattern
255
+ */
256
+ private isPathExcluded(filePath: string, excludePatterns?: string[]): boolean {
257
+ if (!excludePatterns || excludePatterns.length === 0) {
258
+ return false
259
+ }
260
+
261
+ return excludePatterns.some(pattern => minimatch(filePath, pattern))
262
+ }
263
+
264
+ /**
265
+ * Check if a file path matches any of the include patterns.
266
+ * @param filePath - The file path to check
267
+ * @param includePatterns - Array of glob patterns to check against
268
+ * @returns true if the path matches any include pattern, or true if no patterns specified
269
+ */
270
+ private isPathIncluded(filePath: string, includePatterns?: string[]): boolean {
271
+ if (!includePatterns || includePatterns.length === 0) {
272
+ return true
273
+ }
274
+
275
+ return includePatterns.some(pattern => minimatch(filePath, pattern))
276
+ }
277
+
278
+ /**
279
+ * Check if a tool (linter or formatter) is enabled for a specific file path.
280
+ * Respects both the tool's enabled state and its exclude patterns.
281
+ * @param filePath - The file path to check
282
+ * @param tool - The tool to check ('linter' or 'formatter')
283
+ * @returns true if the tool is enabled for this path
284
+ */
285
+ public isEnabledForPath(filePath: string, tool: 'linter' | 'formatter'): boolean {
286
+ const isEnabled = tool === 'linter' ? this.isLinterEnabled : this.isFormatterEnabled
287
+
288
+ if (!isEnabled) {
289
+ return false
290
+ }
291
+
292
+ const toolConfig = tool === 'linter' ? this.config.linter : this.config.formatter
293
+ const excludePatterns = toolConfig?.exclude || []
294
+
295
+ return !this.isPathExcluded(filePath, excludePatterns)
296
+ }
297
+
298
+ /**
299
+ * Check if the linter is enabled for a specific file path.
300
+ * Respects both linter.enabled and linter.exclude patterns.
301
+ * @param filePath - The file path to check
302
+ * @returns true if the linter is enabled for this path
303
+ */
304
+ public isLinterEnabledForPath(filePath: string): boolean {
305
+ return this.isEnabledForPath(filePath, 'linter')
306
+ }
307
+
308
+ /**
309
+ * Check if the formatter is enabled for a specific file path.
310
+ * Respects both formatter.enabled and formatter.exclude patterns.
311
+ * @param filePath - The file path to check
312
+ * @returns true if the formatter is enabled for this path
313
+ */
314
+ public isFormatterEnabledForPath(filePath: string): boolean {
315
+ return this.isEnabledForPath(filePath, 'formatter')
316
+ }
317
+
318
+ /**
319
+ * Check if a specific rule is enabled for a specific file path.
320
+ * Respects linter.enabled, linter.exclude, rule.enabled, rule.include, rule.only, and rule.exclude patterns.
321
+ *
322
+ * Pattern precedence:
323
+ * - If rule.only is specified: Only files matching 'only' patterns (ignores all 'include' patterns)
324
+ * - If rule.only is NOT specified: Files matching 'include' patterns (if specified, additive)
325
+ * - rule.exclude is always applied regardless of 'only' or 'include'
326
+ *
327
+ * @param ruleName - The name of the rule to check
328
+ * @param filePath - The file path to check
329
+ * @returns true if the rule is enabled for this path
330
+ */
331
+ public isRuleEnabledForPath(ruleName: string, filePath: string): boolean {
332
+ if (!this.isLinterEnabledForPath(filePath)) {
333
+ return false
334
+ }
335
+
336
+ if (this.isRuleDisabled(ruleName)) {
337
+ return false
338
+ }
339
+
340
+ const ruleConfig = this.config.linter?.rules?.[ruleName]
341
+ const ruleOnlyPatterns = ruleConfig?.only || []
342
+ const ruleIncludePatterns = ruleConfig?.include || []
343
+ const ruleExcludePatterns = ruleConfig?.exclude || []
344
+
345
+ if (ruleOnlyPatterns.length > 0) {
346
+ if (!this.isPathIncluded(filePath, ruleOnlyPatterns)) {
347
+ return false
348
+ }
349
+ } else if (ruleIncludePatterns.length > 0) {
350
+ if (!this.isPathIncluded(filePath, ruleIncludePatterns)) {
351
+ return false
352
+ }
353
+ }
354
+
355
+ return !this.isPathExcluded(filePath, ruleExcludePatterns)
356
+ }
357
+
358
+ /**
359
+ * Apply configured severity overrides to a lint offense.
360
+ * Returns the configured severity if set, otherwise returns the original severity.
361
+ */
362
+ public getConfiguredSeverity(ruleName: string, defaultSeverity: DiagnosticSeverity): DiagnosticSeverity {
363
+ const ruleConfig = this.config.linter?.rules?.[ruleName]
364
+
365
+ if (ruleConfig && ruleConfig.severity) {
366
+ return ruleConfig.severity
367
+ }
368
+
369
+ return defaultSeverity
370
+ }
371
+
372
+ /**
373
+ * Apply severity overrides from config to an array of offenses.
374
+ * Each offense must have a `rule` and `severity` property.
375
+ */
376
+ public applySeverityOverrides<T extends { rule: string; severity: DiagnosticSeverity }>(offenses: T[]): T[] {
377
+ if (!this.config.linter?.rules) {
378
+ return offenses
379
+ }
380
+
381
+ return offenses.map(offense => {
382
+ const ruleConfig = this.config.linter?.rules?.[offense.rule]
383
+ if (ruleConfig && ruleConfig.severity) {
384
+ return { ...offense, severity: ruleConfig.severity }
385
+ }
386
+ return offense
387
+ })
388
+ }
389
+
390
+ static configPathFromProjectPath(projectPath: string) {
391
+ return path.join(projectPath, this.configPath)
392
+ }
393
+
394
+ /**
395
+ * Get the default file patterns that Herb recognizes.
396
+ * These are the default extensions/patterns used when no custom patterns are specified.
397
+ * @returns Array of glob patterns for HTML+ERB files
398
+ */
399
+ static getDefaultFilePatterns(): string[] {
400
+ return this.getDefaultConfig().files?.include || []
401
+ }
402
+
403
+ /**
404
+ * Check if a .herb.yml config file exists at the given path.
405
+ *
406
+ * @param pathOrFile - Path to directory or explicit config file path
407
+ * @returns True if .herb.yml exists at the location, false otherwise
408
+ */
409
+ static exists(pathOrFile: string): boolean {
410
+ try {
411
+ let configPath: string
412
+
413
+ if (pathOrFile.endsWith(this.configPath)) {
414
+ configPath = pathOrFile
415
+ } else {
416
+ configPath = this.configPathFromProjectPath(pathOrFile)
417
+ }
418
+
419
+ require('fs').statSync(configPath)
420
+
421
+ return true
422
+ } catch {
423
+ return false
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Read raw YAML content from a config file.
429
+ * Handles both explicit .herb.yml paths and directory paths.
430
+ *
431
+ * @param pathOrFile - Path to .herb.yml file or directory containing it
432
+ * @returns string - The raw YAML content
433
+ */
434
+ static readRawYaml(pathOrFile: string): string {
435
+ const configPath = pathOrFile.endsWith(this.configPath)
436
+ ? pathOrFile
437
+ : this.configPathFromProjectPath(pathOrFile)
438
+
439
+ return require('fs').readFileSync(configPath, 'utf-8')
440
+ }
441
+
442
+ /**
443
+ * Load Herb configuration from a file or directory
444
+ *
445
+ * This is the main entry point for loading configuration. It:
446
+ * 1. Discovers the config file by walking up the directory tree
447
+ * 2. Reads and validates the config
448
+ * 3. Merges with defaults for a fully resolved config
449
+ * 4. Auto-creates default config if createIfMissing option is true
450
+ * 5. Prints informative messages to console
451
+ *
452
+ * @param pathOrFile - File path, directory path, or any path to start search from
453
+ * @param options - Loading options
454
+ * @returns Promise<Config> - Fully resolved config instance
455
+ */
456
+ static async load(
457
+ pathOrFile: string,
458
+ options: LoadOptions = {}
459
+ ): Promise<Config> {
460
+ const { silent = false, version = DEFAULT_VERSION, createIfMissing = false, exitOnError = false } = options
461
+
462
+ try {
463
+ if (pathOrFile.endsWith(this.configPath)) {
464
+ return await this.loadFromExplicitPath(pathOrFile, silent, version, exitOnError)
465
+ }
466
+
467
+ const { configPath, projectRoot } = await this.findConfigFile(pathOrFile)
468
+
469
+ if (configPath) {
470
+ return await this.loadFromPath(configPath, projectRoot, silent, version, exitOnError)
471
+ } else if (createIfMissing) {
472
+ return await this.createDefaultConfig(projectRoot, silent, version)
473
+ } else {
474
+ const defaults = this.getDefaultConfig(version)
475
+
476
+ return new Config(projectRoot, defaults)
477
+ }
478
+ } catch (error) {
479
+ if (error instanceof Error) {
480
+ throw error
481
+ }
482
+ throw new Error(`Failed to load Herb configuration: ${error}`)
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Load config for editor/language server use (silent mode, no file creation).
488
+ * This is a convenience method for the common pattern used in editors.
489
+ *
490
+ * @param pathOrFile - Directory path or explicit .herb.yml file path
491
+ * @param version - Optional version string (defaults to package version)
492
+ * @returns Config instance or throws on errors
493
+ */
494
+ static async loadForEditor(pathOrFile: string, version?: string): Promise<Config> {
495
+ return await this.load(pathOrFile, {
496
+ silent: true,
497
+ version,
498
+ createIfMissing: false,
499
+ exitOnError: false
500
+ })
501
+ }
502
+
503
+ /**
504
+ * Load config for CLI use (may create file, show errors).
505
+ * This is a convenience method for the common pattern used in CLI tools.
506
+ *
507
+ * @param pathOrFile - Directory path or explicit .herb.yml file path
508
+ * @param version - Optional version string (defaults to package version)
509
+ * @param createIfMissing - Whether to create config if missing (default: false)
510
+ * @returns Config instance or throws on errors
511
+ */
512
+ static async loadForCLI(
513
+ pathOrFile: string,
514
+ version?: string,
515
+ createIfMissing: boolean = false
516
+ ): Promise<Config> {
517
+ return await this.load(pathOrFile, {
518
+ silent: false,
519
+ version,
520
+ createIfMissing,
521
+ exitOnError: false
522
+ })
523
+ }
524
+
525
+ /**
526
+ * Mutate an existing config file by reading it, validating, merging with a mutation, and writing back.
527
+ * This preserves the user's YAML file structure and only writes what's explicitly configured.
528
+ *
529
+ * @param configPath - Path to the .herb.yml file
530
+ * @param mutation - Partial config to merge (e.g., { linter: { rules: { "rule-name": { enabled: false } } } })
531
+ * @returns Promise<void>
532
+ *
533
+ * @example
534
+ * // Disable a rule in .herb.yml
535
+ * await Config.mutateConfigFile('/path/to/.herb.yml', {
536
+ * linter: {
537
+ * rules: {
538
+ * 'html-img-require-alt': { enabled: false }
539
+ * }
540
+ * }
541
+ * })
542
+ */
543
+ static async mutateConfigFile(
544
+ configPath: string,
545
+ mutation: Partial<HerbConfigOptions>
546
+ ): Promise<void> {
547
+ let yamlContent: string
548
+
549
+ try {
550
+ const existingContent = await fs.readFile(configPath, 'utf-8')
551
+
552
+ if (Object.keys(mutation).length > 0) {
553
+ const document = parseDocument(existingContent)
554
+
555
+ const validation = HerbConfigSchema.safeParse(document.toJSON())
556
+
557
+ if (!validation.success) {
558
+ const readableError = fromZodError(validation.error)
559
+ throw new Error(`Invalid config file at ${configPath}: ${readableError.message}`)
560
+ }
561
+
562
+ if (document.contents) {
563
+ this.applyMutationToDocument(document.contents, mutation)
564
+
565
+ if (!document.get('version')) {
566
+ document.set('version', DEFAULT_VERSION)
567
+ }
568
+ }
569
+
570
+ yamlContent = document.toString()
571
+ } else {
572
+ yamlContent = existingContent
573
+ }
574
+ } catch (error: any) {
575
+ if (error.code !== 'ENOENT') {
576
+ throw error
577
+ }
578
+
579
+ if (Object.keys(mutation).length === 0) {
580
+ yamlContent = configTemplate.replace(
581
+ /^version:\s*[\d.]+$/m,
582
+ `version: ${DEFAULT_VERSION}`
583
+ )
584
+ } else {
585
+ const defaults = this.getDefaultConfig(DEFAULT_VERSION)
586
+ const updated = deepMerge(defaults, mutation) as HerbConfig
587
+
588
+ yamlContent = stringify(updated, {
589
+ indent: 2,
590
+ lineWidth: 0,
591
+ blockQuote: 'literal'
592
+ })
593
+
594
+ yamlContent = this.addYamlSpacing(yamlContent)
595
+ }
596
+ }
597
+
598
+ await fs.writeFile(configPath, yamlContent, 'utf-8')
599
+ }
600
+
601
+ /**
602
+ * Apply mutation to YAML content and return the mutated string (synchronous)
603
+ * Useful for code actions and other scenarios where you need the mutated content
604
+ * without writing to disk.
605
+ *
606
+ * @param yamlContent - The original YAML content (with comments)
607
+ * @param mutation - The mutation to apply
608
+ * @returns The mutated YAML content (with comments preserved)
609
+ */
610
+ static applyMutationToYamlString(
611
+ yamlContent: string,
612
+ mutation: Partial<HerbConfigOptions>
613
+ ): string {
614
+ const document = parseDocument(yamlContent)
615
+
616
+ if (document.contents) {
617
+ this.applyMutationToDocument(document.contents, mutation)
618
+ }
619
+
620
+ return document.toString()
621
+ }
622
+
623
+ /**
624
+ * Create a new config file content with a mutation applied
625
+ * Uses the default template with comments and applies the mutation
626
+ *
627
+ * @param mutation - The mutation to apply to the default config
628
+ * @param version - The version to use (defaults to package version)
629
+ * @returns The config file content as a YAML string
630
+ */
631
+ static createConfigYamlString(
632
+ mutation: Partial<HerbConfigOptions>,
633
+ version: string = DEFAULT_VERSION
634
+ ): string {
635
+ let yamlContent = configTemplate.replace(
636
+ /^version:\s*[\d.]+$/m,
637
+ `version: ${version}`
638
+ )
639
+
640
+ if (Object.keys(mutation).length > 0) {
641
+ yamlContent = this.applyMutationToYamlString(yamlContent, mutation)
642
+ }
643
+
644
+ return yamlContent
645
+ }
646
+
647
+ /**
648
+ * Apply mutation to a YAML document while preserving comments
649
+ * Works recursively to handle nested objects
650
+ */
651
+ private static applyMutationToDocument(node: any, mutation: any): void {
652
+ for (const key in mutation) {
653
+ const value = mutation[key]
654
+
655
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
656
+ let nestedNode = node.get(key)
657
+
658
+ if (!nestedNode || !isMap(nestedNode)) {
659
+ node.set(key, {})
660
+ nestedNode = node.get(key)
661
+ }
662
+
663
+ if (isMap(nestedNode)) {
664
+ this.applyMutationToDocument(nestedNode, value)
665
+ } else {
666
+ node.set(key, value)
667
+ }
668
+ } else {
669
+ node.set(key, value)
670
+ }
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Add spacing between top-level keys and nested rule keys in YAML
676
+ */
677
+ private static addYamlSpacing(yaml: string): string {
678
+ const lines = yaml.split('\n')
679
+ const result: string[] = []
680
+
681
+ for (let i = 0; i < lines.length; i++) {
682
+ const line = lines[i]
683
+ const prevLine = lines[i - 1]
684
+
685
+ if (i > 0 && /^[a-z][\w-]*:/.test(line) && prevLine !== undefined) {
686
+ if (!prevLine.trim().startsWith('version:') && prevLine.trim() !== '') {
687
+ result.push('')
688
+ }
689
+ }
690
+
691
+ if (/^ [a-z][\w-]*:/.test(line) && prevLine && /^ /.test(prevLine)) {
692
+ result.push('')
693
+ }
694
+
695
+ result.push(line)
696
+ }
697
+
698
+ return result.join('\n')
699
+ }
700
+
701
+ /**
702
+ * Create a Config instance from a partial config object
703
+ *
704
+ * Useful for testing and programmatic config creation.
705
+ * Deep merges the partial config with defaults.
706
+ *
707
+ * @param partial - Partial config object
708
+ * @param options - Options including projectPath and version
709
+ * @returns Config instance with fully resolved config
710
+ */
711
+ static fromObject(
712
+ partial: Partial<HerbConfigOptions>,
713
+ options: FromObjectOptions = {}
714
+ ): Config {
715
+ const { projectPath = process.cwd(), version = DEFAULT_VERSION } = options
716
+ const defaults = this.getDefaultConfig(version)
717
+ const merged: HerbConfig = deepMerge(defaults, partial as any)
718
+
719
+ try {
720
+ HerbConfigSchema.parse(merged)
721
+ } catch (error) {
722
+ if (error instanceof ZodError) {
723
+ const validationError = fromZodError(error, {
724
+ prefix: "Configuration validation error",
725
+ })
726
+
727
+ throw new Error(validationError.toString())
728
+ }
729
+
730
+ throw error
731
+ }
732
+
733
+ return new Config(projectPath, merged)
734
+ }
735
+
736
+ /**
737
+ * Find config file by walking up directory tree
738
+ *
739
+ * @param startPath - Path to start searching from (file or directory)
740
+ * @returns Object with configPath (if found) and projectRoot
741
+ */
742
+ private static async findConfigFile(
743
+ startPath: string
744
+ ): Promise<{ configPath: string | null, projectRoot: string }> {
745
+ let currentPath = path.resolve(startPath)
746
+
747
+ try {
748
+ const stats = await fs.stat(currentPath)
749
+
750
+ if (stats.isFile()) {
751
+ currentPath = path.dirname(currentPath)
752
+ }
753
+ } catch {
754
+ currentPath = path.resolve(process.cwd())
755
+ }
756
+
757
+ while (true) {
758
+ const configPath = path.join(currentPath, this.configPath)
759
+
760
+ try {
761
+ await fs.access(configPath)
762
+
763
+ return { configPath, projectRoot: currentPath }
764
+ } catch {
765
+ // Config not in this directory, continue
766
+ }
767
+
768
+ const isProjectRoot = await this.isProjectRoot(currentPath)
769
+
770
+ if (isProjectRoot) {
771
+ return { configPath: null, projectRoot: currentPath }
772
+ }
773
+
774
+ const parentPath = path.dirname(currentPath)
775
+
776
+ if (parentPath === currentPath) {
777
+ return { configPath: null, projectRoot: process.cwd() }
778
+ }
779
+
780
+ currentPath = parentPath
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Check if a directory is a project root
786
+ */
787
+ private static async isProjectRoot(dirPath: string): Promise<boolean> {
788
+ for (const indicator of this.PROJECT_INDICATORS) {
789
+ try {
790
+ await fs.access(path.join(dirPath, indicator))
791
+
792
+ return true
793
+ } catch {
794
+ // Indicator not found, continue checking
795
+ }
796
+ }
797
+ return false
798
+ }
799
+
800
+ /**
801
+ * Load config from explicit path (from --config-file argument)
802
+ */
803
+ private static async loadFromExplicitPath(
804
+ configPath: string,
805
+ silent: boolean,
806
+ version: string,
807
+ exitOnError: boolean
808
+ ): Promise<Config> {
809
+ const resolvedPath = path.resolve(configPath)
810
+
811
+ try {
812
+ const stats = await fs.stat(resolvedPath)
813
+
814
+ if (!stats.isFile()) {
815
+ if (exitOnError) {
816
+ console.error(`\n✗ Config path is not a file: ${resolvedPath}\n`)
817
+
818
+ process.exit(1)
819
+ } else {
820
+ throw new Error(`Config path is not a file: ${resolvedPath}`)
821
+ }
822
+ }
823
+ } catch (error) {
824
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
825
+ if (exitOnError) {
826
+ console.error(`\n✗ Config file not found: ${resolvedPath}\n`)
827
+
828
+ process.exit(1)
829
+ } else {
830
+ throw new Error(`Config file not found: ${resolvedPath}`)
831
+ }
832
+ }
833
+
834
+ throw error
835
+ }
836
+
837
+ const projectRoot = path.dirname(resolvedPath)
838
+ const config = await this.readAndValidateConfig(resolvedPath, projectRoot, version, exitOnError)
839
+
840
+ if (!silent) {
841
+ console.error(`✓ Using Herb config file at ${resolvedPath}`)
842
+ }
843
+
844
+ return config
845
+ }
846
+
847
+ /**
848
+ * Load config from discovered path
849
+ */
850
+ private static async loadFromPath(
851
+ configPath: string,
852
+ projectRoot: string,
853
+ silent: boolean,
854
+ version: string,
855
+ exitOnError: boolean
856
+ ): Promise<Config> {
857
+ const config = await this.readAndValidateConfig(configPath, projectRoot, version, exitOnError)
858
+
859
+ if (!silent) {
860
+ console.error(`✓ Using Herb config file at ${configPath}`)
861
+ }
862
+
863
+ return config
864
+ }
865
+
866
+ /**
867
+ * Create default config at project root
868
+ */
869
+ private static async createDefaultConfig(
870
+ projectRoot: string,
871
+ silent: boolean,
872
+ version: string
873
+ ): Promise<Config> {
874
+ const yamlPath = path.join(projectRoot, '.herb.yaml')
875
+
876
+ try {
877
+ await fs.access(yamlPath)
878
+
879
+ console.error(`\n✗ Found \`.herb.yaml\` file at ${yamlPath}`)
880
+ console.error(` Please rename it to \`.herb.yml\`\n`)
881
+
882
+ process.exit(1)
883
+ } catch {
884
+ // File doesn't exist
885
+ }
886
+
887
+ const configPath = this.configPathFromProjectPath(projectRoot)
888
+
889
+ try {
890
+ await this.mutateConfigFile(configPath, {})
891
+
892
+ if (!silent) {
893
+ console.error(`✓ Created default configuration at ${configPath}`)
894
+ }
895
+ } catch (error) {
896
+ if (!silent) {
897
+ console.error(`⚠ Could not create config file at ${configPath}, using defaults in-memory`)
898
+ }
899
+ }
900
+
901
+ const defaults = this.getDefaultConfig(version)
902
+
903
+ return new Config(projectRoot, defaults)
904
+ }
905
+
906
+ /**
907
+ * Validate config text without loading or exiting process
908
+ * Used by language servers to show diagnostics
909
+ * Returns empty array if valid, array of errors/warnings if invalid
910
+ */
911
+ static async validateConfigText(
912
+ text: string,
913
+ options?: {
914
+ version?: string
915
+ projectPath?: string
916
+ }
917
+ ): Promise<ConfigValidationError[]> {
918
+ const errors: ConfigValidationError[] = []
919
+ const version = options?.version
920
+ const projectPath = options?.projectPath
921
+
922
+ if (projectPath) {
923
+ try {
924
+ const yamlPath = path.join(projectPath, '.herb.yaml')
925
+ await fs.access(yamlPath)
926
+
927
+ errors.push({
928
+ message: 'Found .herb.yaml file. Please rename to .herb.yml',
929
+ path: [],
930
+ code: 'wrong_file_extension',
931
+ severity: 'warning',
932
+ line: 0,
933
+ column: 0
934
+ })
935
+ } catch {
936
+ // .herb.yaml doesn't exist
937
+ }
938
+ }
939
+
940
+ let parsed: any
941
+
942
+ try {
943
+ parsed = parse(text)
944
+ } catch (error: any) {
945
+ let line: number | undefined
946
+ let column: number | undefined
947
+
948
+ const errorMatch = error.message?.match(/at line (\d+), column (\d+)/)
949
+
950
+ if (errorMatch) {
951
+ line = parseInt(errorMatch[1]) - 1
952
+ column = parseInt(errorMatch[2]) - 1
953
+ }
954
+
955
+ errors.push({
956
+ message: error.message || 'Invalid YAML syntax',
957
+ path: [],
958
+ code: 'yaml_syntax_error',
959
+ severity: 'error',
960
+ line,
961
+ column
962
+ })
963
+
964
+ return errors
965
+ }
966
+
967
+ if (parsed === null || parsed === undefined) {
968
+ parsed = {}
969
+ }
970
+
971
+ if (version && parsed.version && parsed.version !== version) {
972
+ errors.push({
973
+ message: `Configuration version (${parsed.version}) doesn't match current version (${version}). Consider updating your configuration.`,
974
+ path: ['version'],
975
+ code: 'version_mismatch',
976
+ severity: 'warning'
977
+ })
978
+ }
979
+
980
+ if (!parsed.version) {
981
+ parsed.version = version || '0.0.0'
982
+ }
983
+
984
+ try {
985
+ HerbConfigSchema.parse(parsed)
986
+ } catch (error) {
987
+ if (error instanceof ZodError) {
988
+ errors.push(...error.issues.map(issue => ({
989
+ message: issue.message,
990
+ path: issue.path,
991
+ code: issue.code,
992
+ severity: 'error' as const
993
+ })))
994
+ }
995
+ }
996
+
997
+ return errors
998
+ }
999
+
1000
+ /**
1001
+ * Read, parse, and validate config file
1002
+ */
1003
+ private static async readAndValidateConfig(
1004
+ configPath: string,
1005
+ projectRoot: string,
1006
+ version: string,
1007
+ exitOnError: boolean = false
1008
+ ): Promise<Config> {
1009
+ try {
1010
+ const content = await fs.readFile(configPath, "utf8")
1011
+
1012
+ let parsed: any
1013
+ try {
1014
+ parsed = parse(content)
1015
+ } catch (error) {
1016
+ if (exitOnError) {
1017
+ console.error(`\n✗ Invalid YAML syntax in ${configPath}`)
1018
+
1019
+ if (error instanceof Error) {
1020
+ console.error(` ${error.message}\n`)
1021
+ }
1022
+ process.exit(1)
1023
+ } else {
1024
+ throw new Error(`Invalid YAML syntax in ${configPath}: ${error instanceof Error ? error.message : String(error)}`)
1025
+ }
1026
+ }
1027
+
1028
+ if (parsed === null || parsed === undefined) {
1029
+ parsed = {}
1030
+ }
1031
+
1032
+ if (!parsed.version) {
1033
+ parsed.version = version
1034
+ }
1035
+
1036
+ try {
1037
+ HerbConfigSchema.parse(parsed)
1038
+ } catch (error) {
1039
+ if (error instanceof ZodError) {
1040
+ const validationError = fromZodError(error, {
1041
+ prefix: `Configuration errors in ${configPath}`,
1042
+ })
1043
+
1044
+ if (exitOnError) {
1045
+ console.error(`\n✗ ${validationError.toString()}\n`)
1046
+
1047
+ process.exit(1)
1048
+ } else {
1049
+ throw new Error(validationError.toString())
1050
+ }
1051
+ }
1052
+
1053
+ throw error
1054
+ }
1055
+
1056
+ if (parsed.version && parsed.version !== version) {
1057
+ console.error(`\n⚠️ Configuration version mismatch in ${configPath}`)
1058
+ console.error(` Config version: ${parsed.version}`)
1059
+ console.error(` Current version: ${version}`)
1060
+ console.error(` Consider updating your .herb.yml file.\n`)
1061
+ }
1062
+
1063
+ const defaults = this.getDefaultConfig(version)
1064
+ const resolved = deepMerge(defaults, parsed as Partial<HerbConfig>)
1065
+
1066
+ resolved.version = version
1067
+
1068
+ return new Config(projectRoot, resolved)
1069
+ } catch (error) {
1070
+ throw error
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Get default configuration object
1076
+ */
1077
+ private static getDefaultConfig(version: string = DEFAULT_VERSION): HerbConfig {
1078
+ return {
1079
+ version,
1080
+ files: {
1081
+ include: [
1082
+ '**/*.html',
1083
+ '**/*.rhtml',
1084
+ '**/*.html.erb',
1085
+ '**/*.html+*.erb',
1086
+ '**/*.turbo_stream.erb'
1087
+ ],
1088
+ exclude: [
1089
+ 'node_modules/**/*',
1090
+ 'vendor/bundle/**/*',
1091
+ 'coverage/**/*',
1092
+ ]
1093
+ },
1094
+ linter: {
1095
+ enabled: true,
1096
+ rules: {}
1097
+ },
1098
+ formatter: {
1099
+ enabled: true,
1100
+ indentWidth: 2,
1101
+ maxLineLength: 80
1102
+ }
1103
+ }
1104
+ }
1105
+ }