@herb-tools/linter 0.9.0 → 0.9.2
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/README.md +2 -2
- package/dist/herb-lint.js +1525 -98
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +546 -87
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +465 -87
- package/dist/index.js.map +1 -1
- package/dist/lint-worker.js +1523 -96
- package/dist/lint-worker.js.map +1 -1
- package/dist/loader.cjs +1078 -94
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +1057 -95
- package/dist/loader.js.map +1 -1
- package/dist/rules/actionview-no-silent-render.js +31 -0
- package/dist/rules/actionview-no-silent-render.js.map +1 -0
- package/dist/rules/erb-no-case-node-children.js +3 -1
- package/dist/rules/erb-no-case-node-children.js.map +1 -1
- package/dist/rules/erb-no-duplicate-branch-elements.js +95 -11
- package/dist/rules/erb-no-duplicate-branch-elements.js.map +1 -1
- package/dist/rules/erb-no-empty-control-flow.js +190 -0
- package/dist/rules/erb-no-empty-control-flow.js.map +1 -0
- package/dist/rules/erb-no-silent-statement.js +44 -0
- package/dist/rules/erb-no-silent-statement.js.map +1 -0
- package/dist/rules/erb-no-unsafe-script-interpolation.js +37 -3
- package/dist/rules/erb-no-unsafe-script-interpolation.js.map +1 -1
- package/dist/rules/html-allowed-script-type.js +1 -1
- package/dist/rules/html-allowed-script-type.js.map +1 -1
- package/dist/rules/index.js +20 -16
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/rule-utils.js +14 -23
- package/dist/rules/rule-utils.js.map +1 -1
- package/dist/rules.js +8 -2
- package/dist/rules.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/rules/actionview-no-silent-render.d.ts +9 -0
- package/dist/types/rules/erb-no-duplicate-branch-elements.d.ts +1 -0
- package/dist/types/rules/erb-no-empty-control-flow.d.ts +8 -0
- package/dist/types/rules/erb-no-silent-statement.d.ts +9 -0
- package/dist/types/rules/erb-no-unsafe-script-interpolation.d.ts +2 -1
- package/dist/types/rules/index.d.ts +20 -16
- package/dist/types/rules/rule-utils.d.ts +8 -11
- package/dist/types/types.d.ts +4 -3
- package/dist/types.js +6 -3
- package/dist/types.js.map +1 -1
- package/docs/rules/README.md +3 -0
- package/docs/rules/actionview-no-silent-render.md +47 -0
- package/docs/rules/erb-no-empty-control-flow.md +83 -0
- package/docs/rules/erb-no-silent-statement.md +53 -0
- package/docs/rules/erb-no-unsafe-script-interpolation.md +70 -3
- package/package.json +8 -8
- package/src/index.ts +21 -0
- package/src/rules/actionview-no-silent-render.ts +44 -0
- package/src/rules/erb-no-case-node-children.ts +3 -1
- package/src/rules/erb-no-duplicate-branch-elements.ts +130 -14
- package/src/rules/erb-no-empty-control-flow.ts +255 -0
- package/src/rules/erb-no-silent-statement.ts +58 -0
- package/src/rules/erb-no-unsafe-script-interpolation.ts +51 -5
- package/src/rules/html-allowed-script-type.ts +1 -1
- package/src/rules/index.ts +21 -16
- package/src/rules/rule-utils.ts +15 -24
- package/src/rules.ts +8 -2
- package/src/types.ts +7 -3
|
@@ -4,6 +4,7 @@ import { IdentityPrinter } from "@herb-tools/printer"
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
isHTMLElementNode,
|
|
7
|
+
isHTMLOpenTagNode,
|
|
7
8
|
isERBIfNode,
|
|
8
9
|
isERBElseNode,
|
|
9
10
|
isERBUnlessNode,
|
|
@@ -27,12 +28,21 @@ type ConditionalNode = ERBIfNode | ERBUnlessNode | ERBCaseNode
|
|
|
27
28
|
|
|
28
29
|
interface DuplicateBranchAutofixContext extends BaseAutofixContext {
|
|
29
30
|
node: Mutable<ConditionalNode>
|
|
31
|
+
allIdentical?: boolean
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
function getSignificantNodes(statements: Node[]): Node[] {
|
|
33
35
|
return statements.filter(node => !isPureWhitespaceNode(node))
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
function trimWhitespaceNodes(nodes: Node[]): Node[] {
|
|
39
|
+
let start = 0
|
|
40
|
+
let end = nodes.length
|
|
41
|
+
while (start < end && isPureWhitespaceNode(nodes[start])) start++
|
|
42
|
+
while (end > start && isPureWhitespaceNode(nodes[end - 1])) end--
|
|
43
|
+
return nodes.slice(start, end)
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
function allEquivalentElements(nodes: Node[]): nodes is HTMLElementNode[] {
|
|
37
47
|
if (nodes.length < 2) return false
|
|
38
48
|
if (!nodes.every(node => isHTMLElementNode(node))) return false
|
|
@@ -172,10 +182,31 @@ class ERBNoDuplicateBranchElementsVisitor extends BaseRuleVisitor<DuplicateBranc
|
|
|
172
182
|
this.markSubsequentIfNodesAsProcessed(node)
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
if (this.allBranchesIdentical(branches)) {
|
|
186
|
+
this.addOffense(
|
|
187
|
+
"All branches of this conditional have identical content. The conditional can be removed.",
|
|
188
|
+
node.location,
|
|
189
|
+
{ node: node as Mutable<ConditionalNode>, allIdentical: true },
|
|
190
|
+
"warning",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
175
196
|
const state = { isFirstOffense: true }
|
|
176
197
|
this.checkBranches(branches, node, state)
|
|
177
198
|
}
|
|
178
199
|
|
|
200
|
+
private allBranchesIdentical(branches: Node[][]): boolean {
|
|
201
|
+
if (branches.length < 2) return false
|
|
202
|
+
|
|
203
|
+
const first = branches[0].map(node => IdentityPrinter.print(node)).join("")
|
|
204
|
+
|
|
205
|
+
return branches.slice(1).every(branch =>
|
|
206
|
+
branch.map(node => IdentityPrinter.print(node)).join("") === first
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
private markSubsequentIfNodesAsProcessed(node: ERBIfNode): void {
|
|
180
211
|
let current: ERBIfNode | ERBElseNode | null = node.subsequent
|
|
181
212
|
|
|
@@ -214,17 +245,37 @@ class ERBNoDuplicateBranchElementsVisitor extends BaseRuleVisitor<DuplicateBranc
|
|
|
214
245
|
|
|
215
246
|
for (const element of elements) {
|
|
216
247
|
const printed = IdentityPrinter.print(element.open_tag)
|
|
217
|
-
const autofixContext = state.isFirstOffense
|
|
218
|
-
? { node: conditionalNode as Mutable<ConditionalNode> }
|
|
219
|
-
: undefined
|
|
220
248
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
249
|
+
if (bodiesMatch) {
|
|
250
|
+
const autofixContext = state.isFirstOffense
|
|
251
|
+
? { node: conditionalNode as Mutable<ConditionalNode> }
|
|
252
|
+
: undefined
|
|
253
|
+
|
|
254
|
+
this.addOffense(
|
|
255
|
+
`The \`${printed}\` element is duplicated across all branches of this conditional and can be moved outside.`,
|
|
256
|
+
element.location,
|
|
257
|
+
autofixContext,
|
|
258
|
+
)
|
|
226
259
|
|
|
227
|
-
|
|
260
|
+
state.isFirstOffense = false
|
|
261
|
+
} else {
|
|
262
|
+
const autofixContext = state.isFirstOffense
|
|
263
|
+
? { node: conditionalNode as Mutable<ConditionalNode> }
|
|
264
|
+
: undefined
|
|
265
|
+
|
|
266
|
+
const tagNameLocation = isHTMLOpenTagNode(element.open_tag) && element.open_tag.tag_name?.location
|
|
267
|
+
? element.open_tag.tag_name.location
|
|
268
|
+
: element?.open_tag?.location || element.location
|
|
269
|
+
|
|
270
|
+
this.addOffense(
|
|
271
|
+
`The \`${printed}\` tag is repeated across all branches with different content. Consider extracting the shared tag outside the conditional.`,
|
|
272
|
+
tagNameLocation,
|
|
273
|
+
autofixContext,
|
|
274
|
+
"hint",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
state.isFirstOffense = false
|
|
278
|
+
}
|
|
228
279
|
}
|
|
229
280
|
|
|
230
281
|
if (!bodiesMatch && bodies.every(body => body.length > 0)) {
|
|
@@ -260,6 +311,18 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
|
|
|
260
311
|
const branches = collectBranches(conditionalNode as ConditionalNode)
|
|
261
312
|
if (!branches) return null
|
|
262
313
|
|
|
314
|
+
if (offense.autofixContext.allIdentical) {
|
|
315
|
+
const parentInfo = findParentArray(result.value, conditionalNode as unknown as Node)
|
|
316
|
+
if (!parentInfo) return null
|
|
317
|
+
|
|
318
|
+
const { array: parentArray, index: conditionalIndex } = parentInfo
|
|
319
|
+
const firstBranchContent = trimWhitespaceNodes(branches[0])
|
|
320
|
+
|
|
321
|
+
parentArray.splice(conditionalIndex, 1, ...firstBranchContent)
|
|
322
|
+
|
|
323
|
+
return result
|
|
324
|
+
}
|
|
325
|
+
|
|
263
326
|
const significantBranches = branches.map(getSignificantNodes)
|
|
264
327
|
if (significantBranches.some(branch => branch.length === 0)) return null
|
|
265
328
|
|
|
@@ -274,24 +337,57 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
|
|
|
274
337
|
|
|
275
338
|
let { array: parentArray, index: conditionalIndex } = parentInfo
|
|
276
339
|
let hasWrapped = false
|
|
340
|
+
let didMutate = false
|
|
341
|
+
let failedToHoistPrefix = false
|
|
342
|
+
let hoistedBefore = false
|
|
277
343
|
|
|
278
344
|
const hoistElement = (elements: HTMLElementNode[], position: "before" | "after"): void => {
|
|
345
|
+
const actualPosition = (position === "before" && failedToHoistPrefix) ? "after" : position
|
|
279
346
|
const bodiesMatch = elements.every(element => IdentityPrinter.print(element) === IdentityPrinter.print(elements[0]))
|
|
280
347
|
|
|
281
348
|
if (bodiesMatch) {
|
|
349
|
+
if (actualPosition === "after") {
|
|
350
|
+
const currentLengths = branches.map(b => getSignificantNodes(b as Node[]).length)
|
|
351
|
+
if (currentLengths.some(l => l !== currentLengths[0])) return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (actualPosition === "after" && position === "before") {
|
|
355
|
+
const isAtEnd = branches.every((branch, index) => {
|
|
356
|
+
const nodes = getSignificantNodes(branch as Node[])
|
|
357
|
+
|
|
358
|
+
return nodes.length > 0 && nodes[nodes.length - 1] === elements[index]
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
if (!isAtEnd) return
|
|
362
|
+
}
|
|
363
|
+
|
|
282
364
|
for (let i = 0; i < branches.length; i++) {
|
|
283
365
|
removeNodeFromArray(branches[i] as Node[], elements[i])
|
|
284
366
|
}
|
|
285
367
|
|
|
286
|
-
if (
|
|
287
|
-
parentArray.splice(conditionalIndex, 0, elements[0])
|
|
288
|
-
conditionalIndex
|
|
368
|
+
if (actualPosition === "before") {
|
|
369
|
+
parentArray.splice(conditionalIndex, 0, elements[0], createLiteral("\n"))
|
|
370
|
+
conditionalIndex += 2
|
|
371
|
+
hoistedBefore = true
|
|
289
372
|
} else {
|
|
290
|
-
parentArray.splice(conditionalIndex + 1, 0, elements[0])
|
|
373
|
+
parentArray.splice(conditionalIndex + 1, 0, createLiteral("\n"), elements[0])
|
|
291
374
|
}
|
|
375
|
+
|
|
376
|
+
didMutate = true
|
|
292
377
|
} else {
|
|
293
378
|
if (hasWrapped) return
|
|
294
379
|
|
|
380
|
+
const canWrap = branches.every((branch, index) => {
|
|
381
|
+
const remaining = getSignificantNodes(branch)
|
|
382
|
+
|
|
383
|
+
return remaining.length === 1 && remaining[0] === elements[index]
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
if (!canWrap) {
|
|
387
|
+
if (position === "before") failedToHoistPrefix = true
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
295
391
|
for (let i = 0; i < branches.length; i++) {
|
|
296
392
|
replaceNodeWithBody(branches[i] as Node[], elements[i])
|
|
297
393
|
}
|
|
@@ -302,6 +398,7 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
|
|
|
302
398
|
parentArray = wrapper.body as Node[]
|
|
303
399
|
conditionalIndex = 1
|
|
304
400
|
hasWrapped = true
|
|
401
|
+
didMutate = true
|
|
305
402
|
}
|
|
306
403
|
}
|
|
307
404
|
|
|
@@ -315,6 +412,25 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
|
|
|
315
412
|
hoistElement(elements, "after")
|
|
316
413
|
}
|
|
317
414
|
|
|
318
|
-
|
|
415
|
+
if (!hasWrapped && hoistedBefore) {
|
|
416
|
+
const remaining = branches.map(branch => getSignificantNodes(branch as Node[]))
|
|
417
|
+
|
|
418
|
+
if (remaining.every(branch => branch.length === 1) && allEquivalentElements(remaining.map(b => b[0]))) {
|
|
419
|
+
const elements = remaining.map(b => b[0] as HTMLElementNode)
|
|
420
|
+
const bodiesMatch = elements.every(el => IdentityPrinter.print(el) === IdentityPrinter.print(elements[0]))
|
|
421
|
+
|
|
422
|
+
if (!bodiesMatch && elements.every(el => el.body.length > 0)) {
|
|
423
|
+
for (let i = 0; i < branches.length; i++) {
|
|
424
|
+
replaceNodeWithBody(branches[i] as Node[], elements[i])
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const wrapper = createWrapper(elements[0], [createLiteral("\n"), conditionalNode as unknown as Node, createLiteral("\n")])
|
|
428
|
+
parentArray[conditionalIndex] = wrapper
|
|
429
|
+
didMutate = true
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return didMutate ? result : null
|
|
319
435
|
}
|
|
320
436
|
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
|
+
import { ParserRule } from "../types.js"
|
|
3
|
+
import { isHTMLTextNode, isERBIfNode, isERBElseNode, Location } from "@herb-tools/core"
|
|
4
|
+
|
|
5
|
+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
|
|
6
|
+
import type {
|
|
7
|
+
Node,
|
|
8
|
+
ERBIfNode,
|
|
9
|
+
ERBElseNode,
|
|
10
|
+
ERBUnlessNode,
|
|
11
|
+
ERBForNode,
|
|
12
|
+
ERBWhileNode,
|
|
13
|
+
ERBUntilNode,
|
|
14
|
+
ERBWhenNode,
|
|
15
|
+
ERBBeginNode,
|
|
16
|
+
ERBRescueNode,
|
|
17
|
+
ERBEnsureNode,
|
|
18
|
+
ERBBlockNode,
|
|
19
|
+
ERBInNode,
|
|
20
|
+
ParseResult,
|
|
21
|
+
} from "@herb-tools/core"
|
|
22
|
+
|
|
23
|
+
class ERBNoEmptyControlFlowVisitor extends BaseRuleVisitor {
|
|
24
|
+
private processedIfNodes: Set<ERBIfNode> = new Set()
|
|
25
|
+
private processedElseNodes: Set<ERBElseNode> = new Set()
|
|
26
|
+
|
|
27
|
+
visitERBIfNode(node: ERBIfNode): void {
|
|
28
|
+
if (this.processedIfNodes.has(node)) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.markIfChainAsProcessed(node)
|
|
33
|
+
this.markElseNodesInIfChain(node)
|
|
34
|
+
|
|
35
|
+
const entireChainEmpty = this.isEntireIfChainEmpty(node)
|
|
36
|
+
|
|
37
|
+
if (entireChainEmpty) {
|
|
38
|
+
this.addEmptyBlockOffense(node, node.statements, "if")
|
|
39
|
+
} else {
|
|
40
|
+
this.checkIfChainParts(node)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.visitChildNodes(node)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
visitERBElseNode(node: ERBElseNode): void {
|
|
47
|
+
if (this.processedElseNodes.has(node)) {
|
|
48
|
+
this.visitChildNodes(node)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.addEmptyBlockOffense(node, node.statements, "else")
|
|
53
|
+
this.visitChildNodes(node)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
visitERBUnlessNode(node: ERBUnlessNode): void {
|
|
57
|
+
const unlessHasContent = this.statementsHaveContent(node.statements)
|
|
58
|
+
const elseHasContent = node.else_clause && this.statementsHaveContent(node.else_clause.statements)
|
|
59
|
+
|
|
60
|
+
if (node.else_clause) {
|
|
61
|
+
this.processedElseNodes.add(node.else_clause)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entireBlockEmpty = !unlessHasContent && !elseHasContent
|
|
65
|
+
|
|
66
|
+
if (entireBlockEmpty) {
|
|
67
|
+
this.addEmptyBlockOffense(node, node.statements, "unless")
|
|
68
|
+
} else {
|
|
69
|
+
if (!unlessHasContent) {
|
|
70
|
+
this.addEmptyBlockOffenseWithEnd(node, node.statements, "unless", node.else_clause)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (node.else_clause && !elseHasContent) {
|
|
74
|
+
this.addEmptyBlockOffense(node.else_clause, node.else_clause.statements, "else")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.visitChildNodes(node)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
visitERBForNode(node: ERBForNode): void {
|
|
82
|
+
this.addEmptyBlockOffense(node, node.statements, "for")
|
|
83
|
+
this.visitChildNodes(node)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
visitERBWhileNode(node: ERBWhileNode): void {
|
|
87
|
+
this.addEmptyBlockOffense(node, node.statements, "while")
|
|
88
|
+
this.visitChildNodes(node)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
visitERBUntilNode(node: ERBUntilNode): void {
|
|
92
|
+
this.addEmptyBlockOffense(node, node.statements, "until")
|
|
93
|
+
this.visitChildNodes(node)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
visitERBWhenNode(node: ERBWhenNode): void {
|
|
97
|
+
if (!node.then_keyword) {
|
|
98
|
+
this.addEmptyBlockOffense(node, node.statements, "when")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.visitChildNodes(node)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
visitERBInNode(node: ERBInNode): void {
|
|
105
|
+
if (!node.then_keyword) {
|
|
106
|
+
this.addEmptyBlockOffense(node, node.statements, "in")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.visitChildNodes(node)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
visitERBBeginNode(node: ERBBeginNode): void {
|
|
113
|
+
this.addEmptyBlockOffense(node, node.statements, "begin")
|
|
114
|
+
this.visitChildNodes(node)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
visitERBRescueNode(node: ERBRescueNode): void {
|
|
118
|
+
this.addEmptyBlockOffense(node, node.statements, "rescue")
|
|
119
|
+
this.visitChildNodes(node)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
visitERBEnsureNode(node: ERBEnsureNode): void {
|
|
123
|
+
this.addEmptyBlockOffense(node, node.statements, "ensure")
|
|
124
|
+
this.visitChildNodes(node)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
visitERBBlockNode(node: ERBBlockNode): void {
|
|
128
|
+
this.addEmptyBlockOffense(node, node.body, "do")
|
|
129
|
+
this.visitChildNodes(node)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private addEmptyBlockOffense(node: Node, statements: Node[], blockType: string): void {
|
|
133
|
+
this.addEmptyBlockOffenseWithEnd(node, statements, blockType, null)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private addEmptyBlockOffenseWithEnd(node: Node, statements: Node[], blockType: string, subsequentNode: Node | null): void {
|
|
137
|
+
if (this.statementsHaveContent(statements)) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const startLocation = node.location.start
|
|
142
|
+
const endLocation = subsequentNode
|
|
143
|
+
? subsequentNode.location.start
|
|
144
|
+
: node.location.end
|
|
145
|
+
|
|
146
|
+
const location = Location.from(
|
|
147
|
+
startLocation.line, startLocation.column,
|
|
148
|
+
endLocation.line, endLocation.column,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const offense = this.createOffense(
|
|
152
|
+
`Empty ${blockType} block: this control flow statement has no content`,
|
|
153
|
+
location,
|
|
154
|
+
)
|
|
155
|
+
offense.tags = ["unnecessary"]
|
|
156
|
+
this.offenses.push(offense)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private statementsHaveContent(statements: Node[]): boolean {
|
|
160
|
+
return statements.some(statement => {
|
|
161
|
+
if (isHTMLTextNode(statement)) {
|
|
162
|
+
return statement.content.trim() !== ""
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return true
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private markIfChainAsProcessed(node: ERBIfNode): void {
|
|
170
|
+
this.processedIfNodes.add(node)
|
|
171
|
+
this.traverseSubsequentNodes(node.subsequent, (current) => {
|
|
172
|
+
if (isERBIfNode(current)) {
|
|
173
|
+
this.processedIfNodes.add(current)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private markElseNodesInIfChain(node: ERBIfNode): void {
|
|
179
|
+
this.traverseSubsequentNodes(node.subsequent, (current) => {
|
|
180
|
+
if (isERBElseNode(current)) {
|
|
181
|
+
this.processedElseNodes.add(current)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private traverseSubsequentNodes(startNode: Node | null, callback: (node: ERBIfNode | ERBElseNode) => void): void {
|
|
187
|
+
let current: Node | null = startNode
|
|
188
|
+
while (current) {
|
|
189
|
+
if (isERBIfNode(current)) {
|
|
190
|
+
callback(current)
|
|
191
|
+
current = current.subsequent
|
|
192
|
+
} else if (isERBElseNode(current)) {
|
|
193
|
+
callback(current)
|
|
194
|
+
break
|
|
195
|
+
} else {
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private checkIfChainParts(node: ERBIfNode): void {
|
|
202
|
+
if (!this.statementsHaveContent(node.statements)) {
|
|
203
|
+
this.addEmptyBlockOffenseWithEnd(node, node.statements, "if", node.subsequent)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.traverseSubsequentNodes(node.subsequent, (current) => {
|
|
207
|
+
if (this.statementsHaveContent(current.statements)) {
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const blockType = isERBIfNode(current) ? "elsif" : "else"
|
|
212
|
+
const nextSubsequent = isERBIfNode(current) ? current.subsequent : null
|
|
213
|
+
|
|
214
|
+
if (nextSubsequent) {
|
|
215
|
+
this.addEmptyBlockOffenseWithEnd(current, current.statements, blockType, nextSubsequent)
|
|
216
|
+
} else {
|
|
217
|
+
this.addEmptyBlockOffense(current, current.statements, blockType)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private isEntireIfChainEmpty(node: ERBIfNode): boolean {
|
|
223
|
+
if (this.statementsHaveContent(node.statements)) {
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let hasContent = false
|
|
228
|
+
this.traverseSubsequentNodes(node.subsequent, (current) => {
|
|
229
|
+
if (this.statementsHaveContent(current.statements)) {
|
|
230
|
+
hasContent = true
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
return !hasContent
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export class ERBNoEmptyControlFlowRule extends ParserRule {
|
|
239
|
+
static ruleName = "erb-no-empty-control-flow"
|
|
240
|
+
|
|
241
|
+
get defaultConfig(): FullRuleConfig {
|
|
242
|
+
return {
|
|
243
|
+
enabled: true,
|
|
244
|
+
severity: "hint"
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
|
|
249
|
+
const visitor = new ERBNoEmptyControlFlowVisitor(this.ruleName, context)
|
|
250
|
+
|
|
251
|
+
visitor.visit(result.value)
|
|
252
|
+
|
|
253
|
+
return visitor.offenses
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
|
+
import { ParserRule } from "../types.js"
|
|
3
|
+
|
|
4
|
+
import { isERBOutputNode, PrismNode } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
|
|
7
|
+
import type { ParseResult, ERBContentNode, ParserOptions } from "@herb-tools/core"
|
|
8
|
+
|
|
9
|
+
function isAssignmentNode(prismNode: PrismNode): boolean {
|
|
10
|
+
const type: string = prismNode?.constructor?.name
|
|
11
|
+
if (!type) return false
|
|
12
|
+
|
|
13
|
+
return type.endsWith("WriteNode")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class ERBNoSilentStatementVisitor extends BaseRuleVisitor {
|
|
17
|
+
visitERBContentNode(node: ERBContentNode): void {
|
|
18
|
+
if (isERBOutputNode(node)) return
|
|
19
|
+
|
|
20
|
+
const prismNode = node.prismNode
|
|
21
|
+
if (!prismNode) return
|
|
22
|
+
|
|
23
|
+
if (isAssignmentNode(prismNode)) return
|
|
24
|
+
|
|
25
|
+
const content = node.content?.value?.trim()
|
|
26
|
+
if (!content) return
|
|
27
|
+
|
|
28
|
+
this.addOffense(
|
|
29
|
+
`Avoid using silent ERB tags for statements. Move \`${content}\` to a controller, helper, or presenter.`,
|
|
30
|
+
node.location,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ERBNoSilentStatementRule extends ParserRule {
|
|
36
|
+
static ruleName = "erb-no-silent-statement"
|
|
37
|
+
|
|
38
|
+
get defaultConfig(): FullRuleConfig {
|
|
39
|
+
return {
|
|
40
|
+
enabled: false,
|
|
41
|
+
severity: "warning"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get parserOptions(): Partial<ParserOptions> {
|
|
46
|
+
return {
|
|
47
|
+
prism_nodes: true,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
|
|
52
|
+
const visitor = new ERBNoSilentStatementVisitor(this.ruleName, context)
|
|
53
|
+
|
|
54
|
+
visitor.visit(result.value)
|
|
55
|
+
|
|
56
|
+
return visitor.offenses
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ParserRule } from "../types.js"
|
|
2
2
|
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
3
|
+
import { PrismVisitor } from "@herb-tools/core"
|
|
4
|
+
|
|
3
5
|
import {
|
|
4
6
|
getTagLocalName,
|
|
5
7
|
getAttribute,
|
|
@@ -10,9 +12,34 @@ import {
|
|
|
10
12
|
} from "@herb-tools/core"
|
|
11
13
|
|
|
12
14
|
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
|
|
13
|
-
import type { ParseResult, HTMLElementNode, Node } from "@herb-tools/core"
|
|
15
|
+
import type { ParseResult, HTMLElementNode, Node, ERBContentNode, ParserOptions, PrismNode } from "@herb-tools/core"
|
|
16
|
+
|
|
17
|
+
const SAFE_METHOD_NAMES = new Set([
|
|
18
|
+
"to_json",
|
|
19
|
+
"json_escape",
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
const ESCAPE_JAVASCRIPT_METHOD_NAMES = new Set([
|
|
23
|
+
"j",
|
|
24
|
+
"escape_javascript",
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
class SafeCallDetector extends PrismVisitor {
|
|
28
|
+
public hasSafeCall = false
|
|
29
|
+
public hasEscapeJavascriptCall = false
|
|
30
|
+
|
|
31
|
+
visitCallNode(node: PrismNode): void {
|
|
32
|
+
if (SAFE_METHOD_NAMES.has(node.name)) {
|
|
33
|
+
this.hasSafeCall = true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (ESCAPE_JAVASCRIPT_METHOD_NAMES.has(node.name)) {
|
|
37
|
+
this.hasEscapeJavascriptCall = true
|
|
38
|
+
}
|
|
14
39
|
|
|
15
|
-
|
|
40
|
+
this.visitChildNodes(node)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
16
43
|
|
|
17
44
|
class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
|
|
18
45
|
visitHTMLElementNode(node: HTMLElementNode): void {
|
|
@@ -35,7 +62,6 @@ class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
|
|
|
35
62
|
const typeValue = typeAttribute ? getStaticAttributeValue(typeAttribute) : null
|
|
36
63
|
|
|
37
64
|
if (typeValue === "text/html") return
|
|
38
|
-
|
|
39
65
|
if (!node.body || node.body.length === 0) return
|
|
40
66
|
|
|
41
67
|
this.checkNodesForUnsafeOutput(node.body)
|
|
@@ -46,9 +72,21 @@ class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
|
|
|
46
72
|
if (!isERBNode(child)) continue
|
|
47
73
|
if (!isERBOutputNode(child)) continue
|
|
48
74
|
|
|
49
|
-
const
|
|
75
|
+
const erbContent = child as ERBContentNode
|
|
76
|
+
const prismNode = erbContent.prismNode
|
|
77
|
+
const detector = new SafeCallDetector()
|
|
78
|
+
|
|
79
|
+
if (prismNode) detector.visit(prismNode)
|
|
80
|
+
if (detector.hasSafeCall) continue
|
|
81
|
+
|
|
82
|
+
if (detector.hasEscapeJavascriptCall) {
|
|
83
|
+
this.addOffense(
|
|
84
|
+
"Avoid `j()` / `escape_javascript()` in `<script>` tags. It is only safe inside quoted string literals. Use `.to_json` instead, which is safe in any position.",
|
|
85
|
+
child.location,
|
|
86
|
+
)
|
|
50
87
|
|
|
51
|
-
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
52
90
|
|
|
53
91
|
this.addOffense(
|
|
54
92
|
"Unsafe ERB output in `<script>` tag. Use `.to_json` to safely serialize values into JavaScript.",
|
|
@@ -68,9 +106,17 @@ export class ERBNoUnsafeScriptInterpolationRule extends ParserRule {
|
|
|
68
106
|
}
|
|
69
107
|
}
|
|
70
108
|
|
|
109
|
+
get parserOptions(): Partial<ParserOptions> {
|
|
110
|
+
return {
|
|
111
|
+
prism_nodes: true,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
71
115
|
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
|
|
72
116
|
const visitor = new ERBNoUnsafeScriptInterpolationVisitor(this.ruleName, context)
|
|
117
|
+
|
|
73
118
|
visitor.visit(result.value)
|
|
119
|
+
|
|
74
120
|
return visitor.offenses
|
|
75
121
|
}
|
|
76
122
|
}
|
|
@@ -5,10 +5,10 @@ import { getTagLocalName, getAttribute, getStaticAttributeValue, hasAttributeVal
|
|
|
5
5
|
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
|
|
6
6
|
import type { HTMLAttributeNode, HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
|
|
7
7
|
|
|
8
|
-
const ALLOWED_TYPES = ["text/javascript"]
|
|
9
8
|
// NOTE: Rules are not configurable for now, keep some sane defaults
|
|
10
9
|
// See https://github.com/marcoroth/herb/issues/1204
|
|
11
10
|
const ALLOW_BLANK = true
|
|
11
|
+
const ALLOWED_TYPES = ["text/javascript", "module", "importmap", "speculationrules"]
|
|
12
12
|
|
|
13
13
|
class AllowedScriptTypeVisitor extends BaseRuleVisitor {
|
|
14
14
|
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|