@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.
@@ -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 { ParserRule } from "../types.js"
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, ERBContentNode, LiteralNode, ParseResult } from "@herb-tools/core"
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(node: HTMLOpenTagNode): void {
14
- const tagName = getTagName(node)
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(node)
21
+ const attributes = getAttributes(openTag)
21
22
  const srcAttribute = findAttributeByName(attributes, "src")
22
23
 
23
- if (!srcAttribute) {
24
- return
25
- }
26
-
27
- if (!srcAttribute.value) {
28
- return
29
- }
24
+ if (!srcAttribute) return
25
+ if (!srcAttribute.value) return
30
26
 
31
- const valueNode = srcAttribute.value as HTMLAttributeValueNode
32
- const hasERBContent = this.containsERBContent(valueNode)
27
+ const node = srcAttribute.value
28
+ const hasERBContent = this.containsERBContent(node)
33
29
 
34
30
  if (hasERBContent) {
35
- const suggestedExpression = this.buildSuggestedExpression(valueNode)
31
+ if (this.isDataUri(node)) return
32
+
33
+ if (this.shouldFlagAsImageTagCandidate(node)) {
34
+ const suggestedExpression = this.buildSuggestedExpression(node)
36
35
 
37
- this.addOffense(
38
- `Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`,
39
- srcAttribute.location,
40
- "warning"
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(valueNode: HTMLAttributeValueNode): boolean {
46
- if (!valueNode.children) return false
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 buildSuggestedExpression(valueNode: HTMLAttributeValueNode): string {
52
- if (!valueNode.children) return "expression"
49
+ private isOnlyERBContent(node: HTMLAttributeValueNode): boolean {
50
+ return node.children.length > 0 && node.children.length === filterNodes(node.children, ERBContentNode).length
51
+ }
53
52
 
54
- let hasText = false
55
- let hasERB = false
53
+ private getContentofFirstChild(node: HTMLAttributeValueNode): string {
54
+ if (!node.children || node.children.length === 0) return ""
56
55
 
57
- for (const child of valueNode.children) {
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
- if (literalNode.content && literalNode.content.trim()) {
64
- hasText = true
65
- }
66
- }
58
+ if (isNode(firstChild, LiteralNode)) {
59
+ return (firstChild.content || "").trim()
67
60
  }
68
61
 
69
- if (hasText && hasERB) {
70
- let result = '"'
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
- result += literalNode.content || ""
81
- }
82
- }
65
+ private isDataUri(node: HTMLAttributeValueNode): boolean {
66
+ return this.getContentofFirstChild(node).startsWith("data:")
67
+ }
83
68
 
84
- result += '"'
69
+ private isFullUrl(node: HTMLAttributeValueNode): boolean {
70
+ const content = this.getContentofFirstChild(node)
85
71
 
86
- return result
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
- if (erbNodes.length === 1) {
93
- return (erbNodes[0].content?.value || "").trim()
94
- } else if (erbNodes.length > 1) {
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
- for (const erbNode of erbNodes) {
98
- result += `#{${(erbNode.content?.value || "").trim()}}`
99
- }
79
+ return true
80
+ }
100
81
 
101
- result += '"'
82
+ private buildSuggestedExpression(node: HTMLAttributeValueNode): string {
83
+ if (!node.children) return "expression"
102
84
 
103
- return result
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 { AttributeVisitorMixin, StaticAttributeStaticValueParams } from "./rule-utils"
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 type { ParseResult } from "@herb-tools/core"
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
- class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
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
- protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
11
- if (attributeName.toLowerCase() !== "id") return
12
- if (!attributeValue) return
47
+ this.currentBranchIds = new Set<string>()
13
48
 
14
- const id = attributeValue.trim()
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
- if (this.documentIds.has(id)) {
17
- this.addOffense(
18
- `Duplicate ID \`${id}\` found. IDs must be unique within a document.`,
19
- attributeNode.location,
20
- "error"
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(id)
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, getTagName, isVoidElement } from "./rule-utils.js"
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
- `Self-closing syntax \`<${tagName} />\` is not allowed in HTML. ${instead}`,
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
 
@@ -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
  */