Skip to content

Commit 5942e12

Browse files
authored
Linter: Implement erb-no-silent-statement rule (#1403)
This pull request implements a new `erb-no-silent-statement` linter rule that flags all `<%` tags that aren't used for control flow or any form of assignments. Since this is quite a radical rule, I decided to disable this one by default for now.
1 parent 2503efa commit 5942e12

File tree

6 files changed

+234
-0
lines changed

6 files changed

+234
-0
lines changed

javascript/packages/linter/docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This page contains documentation for all Herb Linter rules.
2121
- [`erb-no-output-in-attribute-name`](./erb-no-output-in-attribute-name.md) - Disallow ERB output in attribute names
2222
- [`erb-no-output-in-attribute-position`](./erb-no-output-in-attribute-position.md) - Disallow ERB output in attribute position
2323
- [`erb-no-raw-output-in-attribute-value`](./erb-no-raw-output-in-attribute-value.md) - Disallow `<%==` in attribute values
24+
- [`erb-no-silent-statement`](./erb-no-silent-statement.md) - Disallow silent ERB statements
2425
- [`erb-no-silent-tag-in-attribute-name`](./erb-no-silent-tag-in-attribute-name.md) - Disallow ERB silent tags in HTML attribute names
2526
- [`erb-no-statement-in-script`](./erb-no-statement-in-script.md) - Disallow ERB statements inside `<script>` tags
2627
- [`erb-no-then-in-control-flow`](./erb-no-then-in-control-flow.md) - Disallow `then` in ERB control flow expressions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Linter Rule: Disallow silent ERB statements
2+
3+
**Rule:** `erb-no-silent-statement`
4+
5+
## Description
6+
7+
Disallow silent ERB tags (`<% %>`) that execute statements whose return value is discarded. Logic like method calls should live in controllers, helpers, or presenters, not in views. Assignments are allowed since they are pragmatic for DRYing up templates.
8+
9+
## Rationale
10+
11+
Silent ERB tags that aren't control flow or assignments are a code smell. They execute Ruby code whose return value is silently discarded, which usually means the logic belongs in a controller, helper, or presenter rather than the view.
12+
13+
## Examples
14+
15+
### ✅ Good
16+
17+
```erb
18+
<%= title %>
19+
<%= render "partial" %>
20+
```
21+
22+
```erb
23+
<% x = 1 %>
24+
<% @title = "Hello" %>
25+
<% x ||= default_value %>
26+
<% x += 1 %>
27+
```
28+
29+
```erb
30+
<% if user.admin? %>
31+
Admin tools
32+
<% end %>
33+
```
34+
35+
```erb
36+
<% users.each do |user| %>
37+
<p><%= user.name %></p>
38+
<% end %>
39+
```
40+
41+
### 🚫 Bad
42+
43+
```erb
44+
<% some_method %>
45+
```
46+
47+
```erb
48+
<% helper_call(arg) %>
49+
```
50+
51+
## References
52+
53+
\-

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.j
1919
import { ERBNoOutputInAttributeNameRule } from "./rules/erb-no-output-in-attribute-name.js"
2020
import { ERBNoOutputInAttributePositionRule } from "./rules/erb-no-output-in-attribute-position.js"
2121
import { ERBNoRawOutputInAttributeValueRule } from "./rules/erb-no-raw-output-in-attribute-value.js"
22+
import { ERBNoSilentStatementRule } from "./rules/erb-no-silent-statement.js"
2223
import { ERBNoSilentTagInAttributeNameRule } from "./rules/erb-no-silent-tag-in-attribute-name.js"
2324
import { ERBNoStatementInScriptRule } from "./rules/erb-no-statement-in-script.js"
2425
import { ERBNoThenInControlFlowRule } from "./rules/erb-no-then-in-control-flow.js"
@@ -103,6 +104,7 @@ export const rules: RuleClass[] = [
103104
ERBNoOutputInAttributeNameRule,
104105
ERBNoOutputInAttributePositionRule,
105106
ERBNoRawOutputInAttributeValueRule,
107+
ERBNoSilentStatementRule,
106108
ERBNoSilentTagInAttributeNameRule,
107109
ERBNoStatementInScriptRule,
108110
ERBNoThenInControlFlowRule,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

javascript/packages/linter/src/rules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export * from "./erb-no-output-control-flow.js"
2020
export * from "./erb-no-output-in-attribute-name.js"
2121
export * from "./erb-no-output-in-attribute-position.js"
2222
export * from "./erb-no-raw-output-in-attribute-value.js"
23+
export * from "./erb-no-silent-statement.js"
2324
export * from "./erb-no-silent-tag-in-attribute-name.js"
2425
export * from "./erb-no-statement-in-script.js"
2526
export * from "./erb-no-then-in-control-flow.js"
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import dedent from "dedent"
2+
import { describe, test } from "vitest"
3+
import { ERBNoSilentStatementRule } from "../../src/rules/erb-no-silent-statement.js"
4+
import { createLinterTest } from "../helpers/linter-test-helper.js"
5+
6+
const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(ERBNoSilentStatementRule)
7+
8+
describe("ERBNoSilentStatementRule", () => {
9+
test("should not report for output tags", () => {
10+
expectNoOffenses(dedent`
11+
<div>
12+
<%= title %>
13+
<%= render "partial" %>
14+
<%== raw_html %>
15+
</div>
16+
`)
17+
})
18+
19+
test("should not report for local variable assignments", () => {
20+
expectNoOffenses(dedent`
21+
<div>
22+
<% x = 1 %>
23+
<% name = "hello" %>
24+
</div>
25+
`)
26+
})
27+
28+
test("should not report for instance variable assignments", () => {
29+
expectNoOffenses(dedent`
30+
<div>
31+
<% @title = "Hello" %>
32+
</div>
33+
`)
34+
})
35+
36+
test("should not report for or-assignment operators", () => {
37+
expectNoOffenses(dedent`
38+
<div>
39+
<% x ||= default_value %>
40+
<% @count ||= 0 %>
41+
</div>
42+
`)
43+
})
44+
45+
test("should not report for and-assignment operators", () => {
46+
expectNoOffenses(dedent`
47+
<div>
48+
<% x &&= other_value %>
49+
</div>
50+
`)
51+
})
52+
53+
test("should not report for operator assignments", () => {
54+
expectNoOffenses(dedent`
55+
<div>
56+
<% x += 1 %>
57+
<% @count -= 1 %>
58+
</div>
59+
`)
60+
})
61+
62+
test("should not report for control flow statements", () => {
63+
expectNoOffenses(dedent`
64+
<% if user.admin? %>
65+
Admin tools
66+
<% end %>
67+
68+
<% unless user.banned? %>
69+
Content
70+
<% end %>
71+
72+
<% user.posts.each do |post| %>
73+
<p><%= post.title %></p>
74+
<% end %>
75+
`)
76+
})
77+
78+
test("should report for method calls", () => {
79+
expectWarning(
80+
'Avoid using silent ERB tags for statements. Move `some_method` to a controller, helper, or presenter.',
81+
{ line: 2, column: 2 },
82+
)
83+
84+
assertOffenses(dedent`
85+
<div>
86+
<% some_method %>
87+
</div>
88+
`)
89+
})
90+
91+
test("should report for method calls with arguments", () => {
92+
expectWarning(
93+
'Avoid using silent ERB tags for statements. Move `helper_call(arg)` to a controller, helper, or presenter.',
94+
{ line: 2, column: 2 },
95+
)
96+
97+
assertOffenses(dedent`
98+
<div>
99+
<% helper_call(arg) %>
100+
</div>
101+
`)
102+
})
103+
104+
test("should not report for constant assignments", () => {
105+
expectNoOffenses(dedent`
106+
<div>
107+
<% CONSTANT = "value" %>
108+
</div>
109+
`)
110+
})
111+
112+
test("should not report for multi-assignment", () => {
113+
expectNoOffenses(dedent`
114+
<div>
115+
<% a, b = 1, 2 %>
116+
</div>
117+
`)
118+
})
119+
})

0 commit comments

Comments
 (0)