@herb-tools/formatter 0.8.10 → 0.9.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/formatter.ts CHANGED
@@ -6,7 +6,7 @@ import { hasFormatterIgnoreDirective } from "./format-ignore.js"
6
6
 
7
7
  import type { Config } from "@herb-tools/config"
8
8
  import type { RewriteContext } from "@herb-tools/rewriter"
9
- import type { HerbBackend, ParseResult } from "@herb-tools/core"
9
+ import type { HerbBackend, ParseResult, ParseOptions } from "@herb-tools/core"
10
10
  import type { FormatOptions } from "./options.js"
11
11
 
12
12
  /**
@@ -16,6 +16,7 @@ import type { FormatOptions } from "./options.js"
16
16
  export class Formatter {
17
17
  private herb: HerbBackend
18
18
  private options: Required<FormatOptions>
19
+ private parseOptions: ParseOptions
19
20
 
20
21
  /**
21
22
  * Creates a Formatter instance from a Config object (recommended).
@@ -48,16 +49,21 @@ export class Formatter {
48
49
  * @param herb - The Herb backend instance for parsing
49
50
  * @param options - Format options (including rewriters)
50
51
  */
51
- constructor(herb: HerbBackend, options: FormatOptions = {}) {
52
+ constructor(herb: HerbBackend, options: FormatOptions = {}, parseOptions: ParseOptions = {}) {
52
53
  this.herb = herb
53
54
  this.options = resolveFormatOptions(options)
55
+ this.parseOptions = parseOptions
54
56
  }
55
57
 
56
58
  /**
57
59
  * Format a source string, optionally overriding format options per call.
58
60
  */
59
61
  format(source: string, options: FormatOptions = {}, filePath?: string): string {
60
- let result = this.parse(source)
62
+ const result = this.parse(source)
63
+
64
+ if (result.options.action_view_helpers) {
65
+ console.warn("[Herb Formatter] Warning: Formatting a document parsed with `action_view_helpers: true`. The result may not be 100% accurate.")
66
+ }
61
67
 
62
68
  if (result.failed) return source
63
69
  if (isScaffoldTemplate(result)) return source
@@ -104,6 +110,6 @@ export class Formatter {
104
110
 
105
111
  private parse(source: string): ParseResult {
106
112
  this.herb.ensureBackend()
107
- return this.herb.parse(source)
113
+ return this.herb.parse(source, this.parseOptions)
108
114
  }
109
115
  }
@@ -0,0 +1,244 @@
1
+ import { Node, HTMLTextNode, HTMLElementNode, HTMLDoctypeNode, ERBContentNode, WhitespaceNode, XMLDeclarationNode } from "@herb-tools/core"
2
+ import { isNode, getTagName, isERBNode, isERBOutputNode, isERBCommentNode, isCommentNode, isERBControlFlowNode } from "@herb-tools/core"
3
+ import { findPreviousMeaningfulSibling, isBlockLevelNode, isContentPreserving, isNonWhitespaceNode } from "./format-helpers.js"
4
+
5
+ import { INLINE_ELEMENTS, SPACEABLE_CONTAINERS } from "./format-helpers.js"
6
+
7
+ /**
8
+ * SpacingAnalyzer determines when blank lines should be inserted between
9
+ * sibling elements. It implements the "rule of three" intelligent spacing
10
+ * system: adds spacing between 3+ meaningful siblings, respects semantic
11
+ * groupings, groups comments with following elements, and preserves
12
+ * user-added spacing.
13
+ */
14
+ export class SpacingAnalyzer {
15
+ private nodeIsMultiline: Map<Node, boolean>
16
+ private tagGroupsCache = new Map<Node[], Map<number, { tagName: string; groupStart: number; groupEnd: number }>>()
17
+ private allSingleLineCache = new Map<Node[], boolean>()
18
+
19
+ constructor(nodeIsMultiline: Map<Node, boolean>) {
20
+ this.nodeIsMultiline = nodeIsMultiline
21
+ }
22
+
23
+ clear(): void {
24
+ this.tagGroupsCache.clear()
25
+ this.allSingleLineCache.clear()
26
+ }
27
+
28
+ /**
29
+ * Determine if spacing should be added between sibling elements
30
+ *
31
+ * This implements the "rule of three" intelligent spacing system:
32
+ * - Adds spacing between 3 or more meaningful siblings
33
+ * - Respects semantic groupings (e.g., ul/li, nav/a stay tight)
34
+ * - Groups comments with following elements
35
+ * - Preserves user-added spacing
36
+ *
37
+ * @param parentElement - The parent element containing the siblings
38
+ * @param siblings - Array of all sibling nodes
39
+ * @param currentIndex - Index of the current node being evaluated
40
+ * @returns true if spacing should be added before the current element
41
+ */
42
+ shouldAddSpacingBetweenSiblings(parentElement: HTMLElementNode | null, siblings: Node[], currentIndex: number): boolean {
43
+ const currentNode = siblings[currentIndex]
44
+ const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex)
45
+ const previousNode = previousMeaningfulIndex !== -1 ? siblings[previousMeaningfulIndex] : null
46
+
47
+ if (previousNode && (isNode(previousNode, XMLDeclarationNode) || isNode(previousNode, HTMLDoctypeNode))) {
48
+ return true
49
+ }
50
+
51
+ const hasMixedContent = siblings.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "")
52
+
53
+ if (hasMixedContent) return false
54
+
55
+ const isCurrentComment = isCommentNode(currentNode)
56
+ const isPreviousComment = previousNode ? isCommentNode(previousNode) : false
57
+ const isCurrentMultiline = this.isMultilineElement(currentNode)
58
+ const isPreviousMultiline = previousNode ? this.isMultilineElement(previousNode) : false
59
+
60
+ if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
61
+ return isPreviousMultiline && isCurrentMultiline
62
+ }
63
+
64
+ if (isPreviousComment && isCurrentComment) {
65
+ return false
66
+ }
67
+
68
+ if (isCurrentMultiline || isPreviousMultiline) {
69
+ return true
70
+ }
71
+
72
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child))
73
+ const parentTagName = parentElement ? getTagName(parentElement) : null
74
+ const isSpaceableContainer = !parentTagName || SPACEABLE_CONTAINERS.has(parentTagName)
75
+ const tagGroups = this.detectTagGroups(siblings)
76
+
77
+ const cached = this.allSingleLineCache.get(siblings)
78
+ let allSingleLineHTMLElements: boolean
79
+ if (cached !== undefined) {
80
+ allSingleLineHTMLElements = cached
81
+ } else {
82
+ allSingleLineHTMLElements = meaningfulSiblings.every(node => isNode(node, HTMLElementNode) && !this.isMultilineElement(node))
83
+ this.allSingleLineCache.set(siblings, allSingleLineHTMLElements)
84
+ }
85
+
86
+ if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
87
+ return false
88
+ }
89
+
90
+ const currentGroup = tagGroups.get(currentIndex)
91
+ const previousGroup = previousNode ? tagGroups.get(previousMeaningfulIndex) : undefined
92
+
93
+ if (currentGroup && previousGroup && currentGroup.groupStart === previousGroup.groupStart && currentGroup.groupEnd === previousGroup.groupEnd) {
94
+ return false
95
+ }
96
+
97
+ if (previousGroup && previousGroup.groupEnd === previousMeaningfulIndex) {
98
+ return true
99
+ }
100
+
101
+ if (allSingleLineHTMLElements && tagGroups.size === 0) {
102
+ return false
103
+ }
104
+
105
+ if (isNode(currentNode, HTMLElementNode)) {
106
+ const currentTagName = getTagName(currentNode)
107
+
108
+ if (currentTagName && INLINE_ELEMENTS.has(currentTagName)) {
109
+ return false
110
+ }
111
+ }
112
+
113
+ const isBlockElement = isBlockLevelNode(currentNode)
114
+ const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode)
115
+ const isComment = isCommentNode(currentNode)
116
+
117
+ return isBlockElement || isERBBlock || isComment
118
+ }
119
+
120
+ /**
121
+ * Check if there's a blank line (double newline) in the nodes at the given index
122
+ */
123
+ hasBlankLineBetween(body: Node[], index: number): boolean {
124
+ for (let lookbackIndex = index - 1; lookbackIndex >= 0 && lookbackIndex >= index - 2; lookbackIndex--) {
125
+ const node = body[lookbackIndex]
126
+
127
+ if (isNode(node, HTMLTextNode) && node.content.includes('\n\n')) {
128
+ return true
129
+ }
130
+
131
+ if (isNode(node, WhitespaceNode)) {
132
+ continue
133
+ }
134
+
135
+ break
136
+ }
137
+
138
+ for (let lookaheadIndex = index; lookaheadIndex < body.length && lookaheadIndex <= index + 1; lookaheadIndex++) {
139
+ const node = body[lookaheadIndex]
140
+
141
+ if (isNode(node, HTMLTextNode) && node.content.includes('\n\n')) {
142
+ return true
143
+ }
144
+
145
+ if (isNode(node, WhitespaceNode)) {
146
+ continue
147
+ }
148
+
149
+ break
150
+ }
151
+
152
+ return false
153
+ }
154
+
155
+ /**
156
+ * Check if a node will render as multiple lines when formatted.
157
+ */
158
+ private isMultilineElement(node: Node): boolean {
159
+ if (isNode(node, ERBContentNode)) {
160
+ return (node.content?.value || "").includes("\n")
161
+ }
162
+
163
+ if (isNode(node, HTMLElementNode) && isContentPreserving(node)) {
164
+ return true
165
+ }
166
+
167
+ const tracked = this.nodeIsMultiline.get(node)
168
+
169
+ if (tracked !== undefined) {
170
+ return tracked
171
+ }
172
+
173
+ return false
174
+ }
175
+
176
+ /**
177
+ * Get a grouping key for a node (tag name for HTML, ERB type for ERB)
178
+ */
179
+ private getGroupingKey(node: Node): string | null {
180
+ if (isNode(node, HTMLElementNode)) {
181
+ return getTagName(node)
182
+ }
183
+
184
+ if (isERBOutputNode(node)) return "erb-output"
185
+ if (isERBCommentNode(node)) return "erb-comment"
186
+ if (isERBNode(node)) return "erb-code"
187
+
188
+ return null
189
+ }
190
+
191
+ /**
192
+ * Detect groups of consecutive same-tag/same-type single-line elements
193
+ * Returns a map of index -> group info for efficient lookup
194
+ */
195
+ private detectTagGroups(siblings: Node[]): Map<number, { tagName: string; groupStart: number; groupEnd: number }> {
196
+ const cached = this.tagGroupsCache.get(siblings)
197
+ if (cached) return cached
198
+
199
+ const groupMap = new Map<number, { tagName: string; groupStart: number; groupEnd: number }>()
200
+ const meaningfulNodes: Array<{ index: number; groupKey: string }> = []
201
+
202
+ for (let i = 0; i < siblings.length; i++) {
203
+ const node = siblings[i]
204
+
205
+ if (!this.isMultilineElement(node)) {
206
+ const groupKey = this.getGroupingKey(node)
207
+
208
+ if (groupKey) {
209
+ meaningfulNodes.push({ index: i, groupKey })
210
+ }
211
+ }
212
+ }
213
+
214
+ let groupStart = 0
215
+
216
+ while (groupStart < meaningfulNodes.length) {
217
+ const startGroupKey = meaningfulNodes[groupStart].groupKey
218
+ let groupEnd = groupStart
219
+
220
+ while (groupEnd + 1 < meaningfulNodes.length && meaningfulNodes[groupEnd + 1].groupKey === startGroupKey) {
221
+ groupEnd++
222
+ }
223
+
224
+ if (groupEnd > groupStart) {
225
+ const groupStartIndex = meaningfulNodes[groupStart].index
226
+ const groupEndIndex = meaningfulNodes[groupEnd].index
227
+
228
+ for (let i = groupStart; i <= groupEnd; i++) {
229
+ groupMap.set(meaningfulNodes[i].index, {
230
+ tagName: startGroupKey,
231
+ groupStart: groupStartIndex,
232
+ groupEnd: groupEndIndex
233
+ })
234
+ }
235
+ }
236
+
237
+ groupStart = groupEnd + 1
238
+ }
239
+
240
+ this.tagGroupsCache.set(siblings, groupMap)
241
+
242
+ return groupMap
243
+ }
244
+ }
@@ -0,0 +1,212 @@
1
+ import { isNode, getTagName, isERBCommentNode, isPureWhitespaceNode } from "@herb-tools/core"
2
+ import { Node, HTMLTextNode, HTMLElementNode, ERBContentNode, WhitespaceNode } from "@herb-tools/core"
3
+
4
+ import type { ContentUnitWithNode } from "./format-helpers.js"
5
+
6
+ import {
7
+ hasWhitespaceBetween,
8
+ isHerbDisableComment,
9
+ isInlineElement,
10
+ isLineBreakingElement,
11
+ } from "./format-helpers.js"
12
+
13
+ import {
14
+ hasWhitespaceBeforeNode as hasWhitespaceBeforeNodeHelper,
15
+ lastUnitEndsWithWhitespace as lastUnitEndsWithWhitespaceHelper,
16
+ tryMergeAtomicAfterText as tryMergeAtomicAfterTextHelper,
17
+ tryMergeTextAfterAtomic as tryMergeTextAfterAtomicHelper,
18
+ } from "./text-flow-helpers.js"
19
+
20
+ /**
21
+ * Interface that the delegate must implement to provide
22
+ * rendering capabilities to the TextFlowAnalyzer.
23
+ */
24
+ export interface TextFlowAnalyzerDelegate {
25
+ tryRenderInlineElement(element: HTMLElementNode): string | null
26
+ renderERBAsString(node: ERBContentNode): string
27
+ }
28
+
29
+ /**
30
+ * TextFlowAnalyzer converts AST nodes into the ContentUnitWithNode[]
31
+ * intermediate representation used by the TextFlowEngine for rendering.
32
+ */
33
+ export class TextFlowAnalyzer {
34
+ private delegate: TextFlowAnalyzerDelegate
35
+
36
+ constructor(delegate: TextFlowAnalyzerDelegate) {
37
+ this.delegate = delegate
38
+ }
39
+
40
+ buildContentUnits(children: Node[]): ContentUnitWithNode[] {
41
+ const result: ContentUnitWithNode[] = []
42
+ let lastProcessedIndex = -1
43
+
44
+ for (let i = 0; i < children.length; i++) {
45
+ const child = children[i]
46
+
47
+ if (isNode(child, WhitespaceNode)) continue
48
+
49
+ if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
50
+ if (lastProcessedIndex >= 0) {
51
+ const hasNonWhitespaceAfter = children.slice(i + 1).some(node =>
52
+ !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node)
53
+ )
54
+
55
+ if (hasNonWhitespaceAfter) {
56
+ const previousNode = children[lastProcessedIndex]
57
+
58
+ if (!isLineBreakingElement(previousNode)) {
59
+ result.push({
60
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
61
+ node: child
62
+ })
63
+ }
64
+ }
65
+ }
66
+
67
+ continue
68
+ }
69
+
70
+ if (isNode(child, HTMLTextNode)) {
71
+ this.processTextNode(result, children, child, i, lastProcessedIndex)
72
+
73
+ lastProcessedIndex = i
74
+ } else if (isNode(child, HTMLElementNode)) {
75
+ const tagName = getTagName(child)
76
+
77
+ if (isInlineElement(tagName)) {
78
+ const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex)
79
+
80
+ if (merged) {
81
+ lastProcessedIndex = i
82
+
83
+ continue
84
+ }
85
+ } else {
86
+ result.push({
87
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
88
+ node: child
89
+ })
90
+ }
91
+
92
+ lastProcessedIndex = i
93
+ } else if (isNode(child, ERBContentNode)) {
94
+ const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex)
95
+
96
+ if (merged) {
97
+ lastProcessedIndex = i
98
+
99
+ continue
100
+ }
101
+
102
+ lastProcessedIndex = i
103
+ } else {
104
+ result.push({
105
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
106
+ node: child
107
+ })
108
+
109
+ lastProcessedIndex = i
110
+ }
111
+ }
112
+
113
+ return result
114
+ }
115
+
116
+ private processTextNode(result: ContentUnitWithNode[], children: Node[], child: HTMLTextNode, index: number, lastProcessedIndex: number): void {
117
+ const isAtomic = child.content === ' '
118
+
119
+ if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
120
+ const hasWhitespace = hasWhitespaceBeforeNodeHelper(children, lastProcessedIndex, index, child)
121
+ const lastUnit = result[result.length - 1]
122
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline')
123
+
124
+ if (lastIsAtomic && !hasWhitespace && tryMergeTextAfterAtomicHelper(result, child)) {
125
+ return
126
+ }
127
+ }
128
+
129
+ result.push({
130
+ unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
131
+ node: child
132
+ })
133
+ }
134
+
135
+ private processInlineElement(result: ContentUnitWithNode[], children: Node[], child: HTMLElementNode, index: number, lastProcessedIndex: number): boolean {
136
+ const inlineContent = this.delegate.tryRenderInlineElement(child)
137
+
138
+ if (inlineContent === null) {
139
+ result.push({
140
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
141
+ node: child
142
+ })
143
+
144
+ return false
145
+ }
146
+
147
+ if (lastProcessedIndex >= 0) {
148
+ const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || lastUnitEndsWithWhitespaceHelper(result)
149
+
150
+ if (!hasWhitespace && tryMergeAtomicAfterTextHelper(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
151
+ return true
152
+ }
153
+ }
154
+
155
+ result.push({
156
+ unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
157
+ node: child
158
+ })
159
+
160
+ return false
161
+ }
162
+
163
+ private processERBContentNode(result: ContentUnitWithNode[], children: Node[], child: ERBContentNode, index: number, lastProcessedIndex: number): boolean {
164
+ const erbContent = this.delegate.renderERBAsString(child)
165
+ const herbDisable = isHerbDisableComment(child)
166
+
167
+ if (lastProcessedIndex >= 0) {
168
+ const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || lastUnitEndsWithWhitespaceHelper(result)
169
+
170
+ if (!hasWhitespace && tryMergeAtomicAfterTextHelper(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
171
+ return true
172
+ }
173
+
174
+ if (hasWhitespace && result.length > 0) {
175
+ const lastUnit = result[result.length - 1]
176
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb')
177
+
178
+ if (lastIsAtomic && !lastUnitEndsWithWhitespaceHelper(result)) {
179
+ result.push({
180
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
181
+ node: null
182
+ })
183
+ }
184
+ }
185
+ }
186
+
187
+ result.push({
188
+ unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable: herbDisable },
189
+ node: child
190
+ })
191
+
192
+ if (isERBCommentNode(child) && !herbDisable) {
193
+ for (let j = index + 1; j < children.length; j++) {
194
+ const nextChild = children[j]
195
+ if (isNode(nextChild, WhitespaceNode)) continue
196
+ if (isPureWhitespaceNode(nextChild)) continue
197
+
198
+ const hasNewlineBefore = isNode(nextChild, HTMLTextNode) && /\n/.test(nextChild.content.split(/\S/)[0] || '')
199
+ if (nextChild.location.start.line > child.location.end.line || hasNewlineBefore) {
200
+ result.push({
201
+ unit: { content: '', type: 'text', isAtomic: false, breaksFlow: true },
202
+ node: null
203
+ })
204
+ }
205
+
206
+ break
207
+ }
208
+ }
209
+
210
+ return false
211
+ }
212
+ }