@herb-tools/linter 0.6.0 → 0.6.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/dist/herb-lint.js +1334 -128
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +919 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +920 -73
- package/dist/index.js.map +1 -1
- package/dist/package.json +5 -4
- package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -60
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +134 -9
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-self-closing.js +12 -5
- package/dist/src/rules/html-no-self-closing.js.map +1 -1
- package/dist/src/rules/rule-utils.js +69 -0
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/types/rules/rule-utils.d.ts +39 -0
- package/dist/types/src/rules/rule-utils.d.ts +39 -0
- package/package.json +5 -4
- package/src/rules/erb-prefer-image-tag-helper.ts +53 -71
- package/src/rules/html-no-duplicate-ids.ts +188 -14
- package/src/rules/html-no-self-closing.ts +13 -8
- package/src/rules/rule-utils.ts +97 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { ParserRule } from "../types.js"
|
|
1
2
|
import { BaseRuleVisitor, getTagName, findAttributeByName, getAttributes } from "./rule-utils.js"
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { ERBToRubyStringPrinter } from "@herb-tools/printer"
|
|
5
|
+
import { filterNodes, ERBContentNode, LiteralNode, isNode } from "@herb-tools/core"
|
|
6
|
+
|
|
4
7
|
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
-
import type { HTMLOpenTagNode, HTMLAttributeValueNode,
|
|
8
|
+
import type { HTMLOpenTagNode, HTMLAttributeValueNode, ParseResult } from "@herb-tools/core"
|
|
6
9
|
|
|
7
10
|
class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
8
11
|
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
@@ -10,101 +13,80 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
|
10
13
|
super.visitHTMLOpenTagNode(node)
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
private checkImgTag(
|
|
14
|
-
const tagName = getTagName(
|
|
16
|
+
private checkImgTag(openTag: HTMLOpenTagNode): void {
|
|
17
|
+
const tagName = getTagName(openTag)
|
|
15
18
|
|
|
16
|
-
if (tagName !== "img")
|
|
17
|
-
return
|
|
18
|
-
}
|
|
19
|
+
if (tagName !== "img") return
|
|
19
20
|
|
|
20
|
-
const attributes = getAttributes(
|
|
21
|
+
const attributes = getAttributes(openTag)
|
|
21
22
|
const srcAttribute = findAttributeByName(attributes, "src")
|
|
22
23
|
|
|
23
|
-
if (!srcAttribute)
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (!srcAttribute.value) {
|
|
28
|
-
return
|
|
29
|
-
}
|
|
24
|
+
if (!srcAttribute) return
|
|
25
|
+
if (!srcAttribute.value) return
|
|
30
26
|
|
|
31
|
-
const
|
|
32
|
-
const hasERBContent = this.containsERBContent(
|
|
27
|
+
const node = srcAttribute.value
|
|
28
|
+
const hasERBContent = this.containsERBContent(node)
|
|
33
29
|
|
|
34
30
|
if (hasERBContent) {
|
|
35
|
-
|
|
31
|
+
if (this.isDataUri(node)) return
|
|
32
|
+
|
|
33
|
+
if (this.shouldFlagAsImageTagCandidate(node)) {
|
|
34
|
+
const suggestedExpression = this.buildSuggestedExpression(node)
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
this.addOffense(
|
|
37
|
+
`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`,
|
|
38
|
+
srcAttribute.location,
|
|
39
|
+
"warning"
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
private containsERBContent(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE")
|
|
45
|
+
private containsERBContent(node: HTMLAttributeValueNode): boolean {
|
|
46
|
+
return filterNodes(node.children, ERBContentNode).length > 0
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
private
|
|
52
|
-
|
|
49
|
+
private isOnlyERBContent(node: HTMLAttributeValueNode): boolean {
|
|
50
|
+
return node.children.length > 0 && node.children.length === filterNodes(node.children, ERBContentNode).length
|
|
51
|
+
}
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
private getContentofFirstChild(node: HTMLAttributeValueNode): string {
|
|
54
|
+
if (!node.children || node.children.length === 0) return ""
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
if (child.type === "AST_ERB_CONTENT_NODE") {
|
|
59
|
-
hasERB = true
|
|
60
|
-
} else if (child.type === "AST_LITERAL_NODE") {
|
|
61
|
-
const literalNode = child as LiteralNode
|
|
56
|
+
const firstChild = node.children[0]
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
}
|
|
58
|
+
if (isNode(firstChild, LiteralNode)) {
|
|
59
|
+
return (firstChild.content || "").trim()
|
|
67
60
|
}
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
for (const child of valueNode.children) {
|
|
73
|
-
if (child.type === "AST_ERB_CONTENT_NODE") {
|
|
74
|
-
const erbNode = child as ERBContentNode
|
|
75
|
-
|
|
76
|
-
result += `#{${(erbNode.content?.value || "").trim()}}`
|
|
77
|
-
} else if (child.type === "AST_LITERAL_NODE") {
|
|
78
|
-
const literalNode = child as LiteralNode
|
|
62
|
+
return ""
|
|
63
|
+
}
|
|
79
64
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
65
|
+
private isDataUri(node: HTMLAttributeValueNode): boolean {
|
|
66
|
+
return this.getContentofFirstChild(node).startsWith("data:")
|
|
67
|
+
}
|
|
83
68
|
|
|
84
|
-
|
|
69
|
+
private isFullUrl(node: HTMLAttributeValueNode): boolean {
|
|
70
|
+
const content = this.getContentofFirstChild(node)
|
|
85
71
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (hasERB && !hasText) {
|
|
90
|
-
const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE") as ERBContentNode[]
|
|
72
|
+
return content.startsWith("http://") || content.startsWith("https://")
|
|
73
|
+
}
|
|
91
74
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
let result = '"'
|
|
75
|
+
private shouldFlagAsImageTagCandidate(node: HTMLAttributeValueNode): boolean {
|
|
76
|
+
if (this.isOnlyERBContent(node)) return true
|
|
77
|
+
if (this.isFullUrl(node)) return false
|
|
96
78
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
100
81
|
|
|
101
|
-
|
|
82
|
+
private buildSuggestedExpression(node: HTMLAttributeValueNode): string {
|
|
83
|
+
if (!node.children) return "expression"
|
|
102
84
|
|
|
103
|
-
|
|
104
|
-
}
|
|
85
|
+
try {
|
|
86
|
+
return ERBToRubyStringPrinter.print(node, { ignoreErrors: false })
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return "expression"
|
|
105
89
|
}
|
|
106
|
-
|
|
107
|
-
return "expression"
|
|
108
90
|
}
|
|
109
91
|
}
|
|
110
92
|
|
|
@@ -1,29 +1,203 @@
|
|
|
1
1
|
import { ParserRule } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { ControlFlowTrackingVisitor, ControlFlowType } from "./rule-utils"
|
|
3
|
+
import { LiteralNode } from "@herb-tools/core"
|
|
4
|
+
import { Printer, IdentityPrinter } from "@herb-tools/printer"
|
|
3
5
|
|
|
4
|
-
import
|
|
6
|
+
import { hasERBOutput, getValidatableStaticContent, isEffectivelyStatic, isNode, getStaticAttributeName, isERBOutputNode } from "@herb-tools/core"
|
|
7
|
+
|
|
8
|
+
import type { ParseResult, HTMLAttributeNode, ERBContentNode } from "@herb-tools/core"
|
|
5
9
|
import type { LintOffense, LintContext } from "../types"
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
interface ControlFlowState {
|
|
12
|
+
previousBranchIds: Set<string>
|
|
13
|
+
previousControlFlowIds: Set<string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface BranchState {
|
|
17
|
+
previousBranchIds: Set<string>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class OutputPrinter extends Printer {
|
|
21
|
+
visitLiteralNode(node: LiteralNode) {
|
|
22
|
+
this.write(IdentityPrinter.print(node))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
visitERBContentNode(node: ERBContentNode) {
|
|
26
|
+
if (isERBOutputNode(node)) {
|
|
27
|
+
this.write(IdentityPrinter.print(node))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor<ControlFlowState, BranchState> {
|
|
8
33
|
private documentIds: Set<string> = new Set<string>()
|
|
34
|
+
private currentBranchIds: Set<string> = new Set<string>()
|
|
35
|
+
private controlFlowIds: Set<string> = new Set<string>()
|
|
36
|
+
|
|
37
|
+
visitHTMLAttributeNode(node: HTMLAttributeNode): void {
|
|
38
|
+
this.checkAttribute(node)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected onEnterControlFlow(_controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): ControlFlowState {
|
|
42
|
+
const stateToRestore: ControlFlowState = {
|
|
43
|
+
previousBranchIds: this.currentBranchIds,
|
|
44
|
+
previousControlFlowIds: this.controlFlowIds
|
|
45
|
+
}
|
|
9
46
|
|
|
10
|
-
|
|
11
|
-
if (attributeName.toLowerCase() !== "id") return
|
|
12
|
-
if (!attributeValue) return
|
|
47
|
+
this.currentBranchIds = new Set<string>()
|
|
13
48
|
|
|
14
|
-
|
|
49
|
+
if (!wasAlreadyInControlFlow) {
|
|
50
|
+
this.controlFlowIds = new Set<string>()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return stateToRestore
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
protected onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: ControlFlowState): void {
|
|
57
|
+
if (controlFlowType === ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
|
|
58
|
+
this.controlFlowIds.forEach(id => this.documentIds.add(id))
|
|
59
|
+
}
|
|
15
60
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
61
|
+
this.currentBranchIds = stateToRestore.previousBranchIds
|
|
62
|
+
this.controlFlowIds = stateToRestore.previousControlFlowIds
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected onEnterBranch(): BranchState {
|
|
66
|
+
const stateToRestore: BranchState = {
|
|
67
|
+
previousBranchIds: this.currentBranchIds
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.isInControlFlow) {
|
|
71
|
+
this.currentBranchIds = new Set<string>()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return stateToRestore
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
protected onExitBranch(_stateToRestore: BranchState): void {}
|
|
78
|
+
|
|
79
|
+
private checkAttribute(attributeNode: HTMLAttributeNode): void {
|
|
80
|
+
if (!this.isIdAttribute(attributeNode)) return
|
|
81
|
+
|
|
82
|
+
const idValue = this.extractIdValue(attributeNode)
|
|
83
|
+
|
|
84
|
+
if (!idValue) return
|
|
85
|
+
if (this.isWhitespaceOnlyId(idValue.identifier)) return
|
|
86
|
+
|
|
87
|
+
this.processIdDuplicate(idValue, attributeNode)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private isIdAttribute(attributeNode: HTMLAttributeNode): boolean {
|
|
91
|
+
if (!attributeNode.name?.children || !attributeNode.value) return false
|
|
92
|
+
|
|
93
|
+
return getStaticAttributeName(attributeNode.name) === "id"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private extractIdValue(attributeNode: HTMLAttributeNode): { identifier: string; shouldTrackDuplicates: boolean } | null {
|
|
97
|
+
const valueNodes = attributeNode.value?.children || []
|
|
98
|
+
|
|
99
|
+
if (hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
22
102
|
|
|
103
|
+
const identifier = isEffectivelyStatic(valueNodes) ? getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes)
|
|
104
|
+
if (!identifier) return null
|
|
105
|
+
|
|
106
|
+
return { identifier, shouldTrackDuplicates: true }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private isWhitespaceOnlyId(identifier: string): boolean {
|
|
110
|
+
return identifier !== '' && identifier.trim() === ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private processIdDuplicate(idValue: { identifier: string; shouldTrackDuplicates: boolean }, attributeNode: HTMLAttributeNode): void {
|
|
114
|
+
const { identifier, shouldTrackDuplicates } = idValue
|
|
115
|
+
|
|
116
|
+
if (!shouldTrackDuplicates) return
|
|
117
|
+
|
|
118
|
+
if (this.isInControlFlow) {
|
|
119
|
+
this.handleControlFlowId(identifier, attributeNode)
|
|
120
|
+
} else {
|
|
121
|
+
this.handleGlobalId(identifier, attributeNode)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private handleControlFlowId(identifier: string, attributeNode: HTMLAttributeNode): void {
|
|
126
|
+
if (this.currentControlFlowType === ControlFlowType.LOOP) {
|
|
127
|
+
this.handleLoopId(identifier, attributeNode)
|
|
128
|
+
} else {
|
|
129
|
+
this.handleConditionalId(identifier, attributeNode)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.currentBranchIds.add(identifier)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private handleLoopId(identifier: string, attributeNode: HTMLAttributeNode): void {
|
|
136
|
+
const isStaticId = this.isStaticId(attributeNode)
|
|
137
|
+
|
|
138
|
+
if (isStaticId) {
|
|
139
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this.currentBranchIds.has(identifier)) {
|
|
144
|
+
this.addSameLoopIterationOffense(identifier, attributeNode.location)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private handleConditionalId(identifier: string, attributeNode: HTMLAttributeNode): void {
|
|
149
|
+
if (this.currentBranchIds.has(identifier)) {
|
|
150
|
+
this.addSameBranchOffense(identifier, attributeNode.location)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (this.documentIds.has(identifier)) {
|
|
155
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.controlFlowIds.add(identifier)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private handleGlobalId(identifier: string, attributeNode: HTMLAttributeNode): void {
|
|
163
|
+
if (this.documentIds.has(identifier)) {
|
|
164
|
+
this.addDuplicateIdOffense(identifier, attributeNode.location)
|
|
23
165
|
return
|
|
24
166
|
}
|
|
25
167
|
|
|
26
|
-
this.documentIds.add(
|
|
168
|
+
this.documentIds.add(identifier)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private isStaticId(attributeNode: HTMLAttributeNode): boolean {
|
|
172
|
+
const valueNodes = attributeNode.value!.children
|
|
173
|
+
const isCompletelyStatic = valueNodes.every(child => isNode(child, LiteralNode))
|
|
174
|
+
const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes)
|
|
175
|
+
|
|
176
|
+
return isCompletelyStatic || isEffectivelyStaticValue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private addDuplicateIdOffense(identifier: string, location: any): void {
|
|
180
|
+
this.addOffense(
|
|
181
|
+
`Duplicate ID \`${identifier}\` found. IDs must be unique within a document.`,
|
|
182
|
+
location,
|
|
183
|
+
"error"
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private addSameLoopIterationOffense(identifier: string, location: any): void {
|
|
188
|
+
this.addOffense(
|
|
189
|
+
`Duplicate ID \`${identifier}\` found within the same loop iteration. IDs must be unique within the same loop iteration.`,
|
|
190
|
+
location,
|
|
191
|
+
"error"
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private addSameBranchOffense(identifier: string, location: any): void {
|
|
196
|
+
this.addOffense(
|
|
197
|
+
`Duplicate ID \`${identifier}\` found within the same control flow branch. IDs must be unique within the same control flow branch.`,
|
|
198
|
+
location,
|
|
199
|
+
"error"
|
|
200
|
+
)
|
|
27
201
|
}
|
|
28
202
|
}
|
|
29
203
|
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import { ParserRule } from "../types.js"
|
|
2
|
-
import { BaseRuleVisitor,
|
|
2
|
+
import { BaseRuleVisitor, isVoidElement } from "./rule-utils.js"
|
|
3
|
+
import { getTagName } from "@herb-tools/core"
|
|
3
4
|
|
|
4
5
|
import type { LintContext, LintOffense } from "../types.js"
|
|
5
|
-
import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
|
|
6
|
+
import type { HTMLOpenTagNode, HTMLElementNode, ParseResult } from "@herb-tools/core"
|
|
6
7
|
|
|
7
8
|
class NoSelfClosingVisitor extends BaseRuleVisitor {
|
|
9
|
+
visitHTMLElementNode(node: HTMLElementNode): void {
|
|
10
|
+
if (getTagName(node) === "svg") {
|
|
11
|
+
this.visit(node.open_tag)
|
|
12
|
+
} else {
|
|
13
|
+
this.visitChildNodes(node)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
9
18
|
if (node.tag_closing?.value === "/>") {
|
|
10
19
|
const tagName = getTagName(node)
|
|
11
|
-
|
|
12
|
-
const shouldBeVoid = tagName ? isVoidElement(tagName) : false
|
|
13
|
-
const instead = shouldBeVoid ? `Use \`<${tagName}>\` instead.` : `Use \`<${tagName}></${tagName}>\` instead.`
|
|
20
|
+
const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`
|
|
14
21
|
|
|
15
22
|
this.addOffense(
|
|
16
|
-
`
|
|
23
|
+
`Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`,
|
|
17
24
|
node.location,
|
|
18
25
|
"error"
|
|
19
26
|
)
|
|
20
27
|
}
|
|
21
|
-
|
|
22
|
-
super.visitHTMLOpenTagNode(node)
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
|
package/src/rules/rule-utils.ts
CHANGED
|
@@ -24,10 +24,18 @@ import type {
|
|
|
24
24
|
Node
|
|
25
25
|
} from "@herb-tools/core"
|
|
26
26
|
|
|
27
|
+
import { IdentityPrinter } from "@herb-tools/printer"
|
|
28
|
+
|
|
27
29
|
import { DEFAULT_LINT_CONTEXT } from "../types.js"
|
|
28
30
|
|
|
31
|
+
import type * as Nodes from "@herb-tools/core"
|
|
29
32
|
import type { LintOffense, LintSeverity, LintContext } from "../types.js"
|
|
30
33
|
|
|
34
|
+
export enum ControlFlowType {
|
|
35
|
+
CONDITIONAL,
|
|
36
|
+
LOOP
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
/**
|
|
32
40
|
* Base visitor class that provides common functionality for rule visitors
|
|
33
41
|
*/
|
|
@@ -65,6 +73,95 @@ export abstract class BaseRuleVisitor extends Visitor {
|
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Mixin that adds control flow tracking capabilities to rule visitors
|
|
78
|
+
* This allows rules to track state across different control flow structures
|
|
79
|
+
* like if/else branches, loops, etc.
|
|
80
|
+
*
|
|
81
|
+
* @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
|
|
82
|
+
* @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
|
|
83
|
+
*/
|
|
84
|
+
export abstract class ControlFlowTrackingVisitor<TControlFlowState = any, TBranchState = any> extends BaseRuleVisitor {
|
|
85
|
+
protected isInControlFlow: boolean = false
|
|
86
|
+
protected currentControlFlowType: ControlFlowType | null = null
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Handle visiting a control flow node with proper scope management
|
|
90
|
+
*/
|
|
91
|
+
protected handleControlFlowNode(node: Node, controlFlowType: ControlFlowType, visitChildren: () => void): void {
|
|
92
|
+
const wasInControlFlow = this.isInControlFlow
|
|
93
|
+
const previousControlFlowType = this.currentControlFlowType
|
|
94
|
+
|
|
95
|
+
this.isInControlFlow = true
|
|
96
|
+
this.currentControlFlowType = controlFlowType
|
|
97
|
+
|
|
98
|
+
const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow)
|
|
99
|
+
|
|
100
|
+
visitChildren()
|
|
101
|
+
|
|
102
|
+
this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore)
|
|
103
|
+
|
|
104
|
+
this.isInControlFlow = wasInControlFlow
|
|
105
|
+
this.currentControlFlowType = previousControlFlowType
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Handle visiting a branch node (like else, when) with proper scope management
|
|
110
|
+
*/
|
|
111
|
+
protected startNewBranch(visitChildren: () => void): void {
|
|
112
|
+
const stateToRestore = this.onEnterBranch()
|
|
113
|
+
|
|
114
|
+
visitChildren()
|
|
115
|
+
|
|
116
|
+
this.onExitBranch(stateToRestore)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
visitERBIfNode(node: Nodes.ERBIfNode): void {
|
|
120
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
visitERBUnlessNode(node: Nodes.ERBUnlessNode): void {
|
|
124
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
visitERBCaseNode(node: Nodes.ERBCaseNode): void {
|
|
128
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
visitERBCaseMatchNode(node: Nodes.ERBCaseMatchNode): void {
|
|
132
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
visitERBWhileNode(node: Nodes.ERBWhileNode): void {
|
|
136
|
+
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
visitERBForNode(node: Nodes.ERBForNode): void {
|
|
140
|
+
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
visitERBUntilNode(node: Nodes.ERBUntilNode): void {
|
|
144
|
+
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
visitERBBlockNode(node: Nodes.ERBBlockNode): void {
|
|
148
|
+
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
visitERBElseNode(node: Nodes.ERBElseNode): void {
|
|
152
|
+
this.startNewBranch(() => super.visitERBElseNode(node))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
visitERBWhenNode(node: Nodes.ERBWhenNode): void {
|
|
156
|
+
this.startNewBranch(() => super.visitERBWhenNode(node))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
protected abstract onEnterControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): TControlFlowState
|
|
160
|
+
protected abstract onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: TControlFlowState): void
|
|
161
|
+
protected abstract onEnterBranch(): TBranchState
|
|
162
|
+
protected abstract onExitBranch(stateToRestore: TBranchState): void
|
|
163
|
+
}
|
|
164
|
+
|
|
68
165
|
/**
|
|
69
166
|
* Gets attributes from an HTMLOpenTagNode
|
|
70
167
|
*/
|