Skip to content

Commit 8d63b85

Browse files
authored
Linter: Implement erb-no-instance-variables-in-partials linter rule (#1355)
Resolves #390 Resolves #544
1 parent 3e0d5be commit 8d63b85

File tree

9 files changed

+319
-4
lines changed

9 files changed

+319
-4
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This page contains documentation for all Herb Linter rules.
1212
- [`erb-no-extra-newline`](./erb-no-extra-newline.md) - Disallow extra newlines.
1313
- [`erb-no-extra-whitespace-inside-tags`](./erb-no-extra-whitespace-inside-tags.md) - Disallow multiple consecutive spaces inside ERB tags
1414
- [`erb-no-inline-case-conditions`](./erb-no-inline-case-conditions.md) - Disallow inline `case`/`when` and `case`/`in` conditions in a single ERB tag
15+
- [`erb-no-instance-variables-in-partials`](./erb-no-instance-variables-in-partials.md) - Disallow instance variables in partials
1516
- [`erb-no-interpolated-class-names`](./erb-no-interpolated-class-names.md) - Disallow ERB interpolation inside CSS class names
1617
- [`erb-no-javascript-tag-helper`](./erb-no-javascript-tag-helper.md) - Disallow `javascript_tag` helper
1718
- [`erb-no-output-control-flow`](./erb-no-output-control-flow.md) - Prevents outputting control flow blocks
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Linter Rule: Disallow instance variables in partials
2+
3+
**Rule:** `erb-no-instance-variables-in-partials`
4+
5+
## Description
6+
7+
Prevent usage of instance variables inside ERB partials. Using instance variables inside partials can cause issues as their dependency is defined outside of the partial itself. This makes partials more fragile and less reusable. Local variables should be passed directly to partial renders.
8+
9+
A partial is any template whose filename begins with an underscore (e.g. `_card.html.erb`).
10+
11+
Instance variables in partials create implicit dependencies on the controller or parent view, making partials harder to reuse, test, and reason about. Passing data as local variables makes the partial's interface explicit and self-documenting.
12+
13+
## Examples
14+
15+
### ✅ Good
16+
17+
```erb [app/views/posts/index.html.erb]
18+
<%= render partial: "posts/card", locals: { post: @post } %>
19+
```
20+
21+
```erb [app/views/posts/_card.html.erb]
22+
<div>
23+
<%= post.title %>
24+
</div>
25+
```
26+
27+
### 🚫 Bad
28+
29+
```erb [app/views/posts/index.html.erb]
30+
<%= render partial: "posts/card" %>
31+
```
32+
33+
```erb [app/views/posts/_card.html.erb]
34+
<div>
35+
<%= @post.title %>
36+
</div>
37+
```
38+
39+
## References
40+
41+
- [Rails Guide: Using Partials](https://guides.rubyonrails.org/layouts_and_rendering.html#using-partials)
42+
- [Rails Guide: Passing Local Variables](https://guides.rubyonrails.org/layouts_and_rendering.html#passing-local-variables)
43+
- [Inspiration: ERB Lint `PartialInstanceVariable` rule](https://github.com/Shopify/erb_lint?tab=readme-ov-file#partialinstancevariable)

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { ERBRightTrimRule } from "./rules/erb-right-trim.js"
2727
import { ERBNoOutputInAttributePositionRule } from "./rules/erb-no-output-in-attribute-position.js"
2828
import { ERBNoOutputInAttributeNameRule } from "./rules/erb-no-output-in-attribute-name.js"
2929
import { ERBStrictLocalsCommentSyntaxRule } from "./rules/erb-strict-locals-comment-syntax.js"
30+
import { ERBNoInstanceVariablesInPartialsRule } from "./rules/erb-no-instance-variables-in-partials.js"
3031
import { ERBStrictLocalsRequiredRule } from "./rules/erb-strict-locals-required.js"
3132

3233
import { HerbDisableCommentValidRuleNameRule } from "./rules/herb-disable-comment-valid-rule-name.js"
@@ -104,6 +105,7 @@ export const rules: RuleClass[] = [
104105
ERBRightTrimRule,
105106
ERBNoOutputInAttributePositionRule,
106107
ERBNoOutputInAttributeNameRule,
108+
ERBNoInstanceVariablesInPartialsRule,
107109
ERBStrictLocalsCommentSyntaxRule,
108110
ERBStrictLocalsRequiredRule,
109111

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { PrismVisitor, PrismNodes } from "@herb-tools/core"
2+
import { ParserRule } from "../types.js"
3+
4+
import { isPartialFile } from "./file-utils.js"
5+
import { locationFromOffset } from "./rule-utils.js"
6+
7+
import type { ParseResult, ParserOptions, PrismLocation } from "@herb-tools/core"
8+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
9+
10+
interface InstanceVariableNode {
11+
name: string
12+
location: PrismLocation
13+
}
14+
15+
type InstanceVariableUsage = "read" | "write"
16+
17+
interface InstanceVariableReference {
18+
name: string
19+
usage: InstanceVariableUsage
20+
startOffset: number
21+
length: number
22+
}
23+
24+
class InstanceVariableCollector extends PrismVisitor {
25+
public readonly instanceVariables: InstanceVariableReference[] = []
26+
27+
visitInstanceVariableReadNode(node: PrismNodes.InstanceVariableReadNode): void {
28+
this.collect(node, "read")
29+
}
30+
31+
visitInstanceVariableWriteNode(node: PrismNodes.InstanceVariableWriteNode): void {
32+
this.collect(node, "write")
33+
}
34+
35+
visitInstanceVariableAndWriteNode(node: PrismNodes.InstanceVariableAndWriteNode): void {
36+
this.collect(node, "write")
37+
}
38+
39+
visitInstanceVariableOrWriteNode(node: PrismNodes.InstanceVariableOrWriteNode): void {
40+
this.collect(node, "write")
41+
}
42+
43+
visitInstanceVariableOperatorWriteNode(node: PrismNodes.InstanceVariableOperatorWriteNode): void {
44+
this.collect(node, "write")
45+
}
46+
47+
visitInstanceVariableTargetNode(node: PrismNodes.InstanceVariableTargetNode): void {
48+
this.collect(node, "write")
49+
}
50+
51+
private collect(node: InstanceVariableNode, usage: InstanceVariableUsage): void {
52+
this.instanceVariables.push({
53+
name: node.name,
54+
usage,
55+
startOffset: node.location.startOffset,
56+
length: node.location.length
57+
})
58+
}
59+
}
60+
61+
export class ERBNoInstanceVariablesInPartialsRule extends ParserRule {
62+
static ruleName = "erb-no-instance-variables-in-partials"
63+
64+
get defaultConfig(): FullRuleConfig {
65+
return {
66+
enabled: true,
67+
severity: "error",
68+
}
69+
}
70+
71+
get parserOptions(): Partial<ParserOptions> {
72+
return {
73+
track_whitespace: true,
74+
prism_program: true,
75+
}
76+
}
77+
78+
isEnabled(_result: ParseResult, context?: Partial<LintContext>): boolean {
79+
return isPartialFile(context?.fileName) === true
80+
}
81+
82+
check(result: ParseResult, _context?: Partial<LintContext>): UnboundLintOffense[] {
83+
const source = result.value.source
84+
const prismNode = result.value.prismNode
85+
86+
if (!prismNode || !source) return []
87+
88+
const collector = new InstanceVariableCollector()
89+
90+
collector.visit(prismNode)
91+
92+
return collector.instanceVariables.map(ivar => {
93+
const location = locationFromOffset(source, ivar.startOffset, ivar.length)
94+
const message = ivar.usage === "read"
95+
? `Avoid using instance variables in partials. Pass \`${ivar.name}\` as a local variable instead.`
96+
: `Avoid setting instance variables in partials. Use a local variable instead of \`${ivar.name}\`.`
97+
98+
return this.createOffense(message, location)
99+
})
100+
}
101+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export * from "./erb-no-unsafe-script-interpolation.js"
2727
export * from "./erb-right-trim.js"
2828
export * from "./erb-no-output-in-attribute-position.js"
2929
export * from "./erb-no-output-in-attribute-name.js"
30+
export * from "./erb-no-instance-variables-in-partials.js"
3031
export * from "./erb-strict-locals-comment-syntax.js"
3132
export * from "./erb-strict-locals-required.js"
3233

javascript/packages/linter/src/rules/rule-utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,15 @@ export function positionFromOffset(source: string, offset: number): Position {
853853
return new Position(line, column)
854854
}
855855

856+
/**
857+
* Creates a Location from a source string, a start offset, and a length.
858+
*/
859+
export function locationFromOffset(source: string, startOffset: number, length: number): Location {
860+
const start = positionFromOffset(source, startOffset)
861+
const end = positionFromOffset(source, startOffset + length)
862+
return Location.from(start.line, start.column, end.line, end.column)
863+
}
864+
856865
/**
857866
* Checks if a position (line, column) is within a node's location range.
858867
* @param node - The node to check

javascript/packages/linter/src/types.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Diagnostic, LexResult, ParseResult } from "@herb-tools/core"
1+
import { Diagnostic, LexResult, ParseResult, Location } from "@herb-tools/core"
22

33
import type { rules } from "./rules.js"
44
import type { Node, ParserOptions } from "@herb-tools/core"
@@ -111,6 +111,18 @@ export abstract class ParserRule<TAutofixContext extends BaseAutofixContext = Ba
111111
return DEFAULT_LINTER_PARSER_OPTIONS
112112
}
113113

114+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
115+
return {
116+
rule: this.ruleName,
117+
code: this.ruleName,
118+
source: "Herb Linter",
119+
message,
120+
location,
121+
autofixContext,
122+
severity,
123+
}
124+
}
125+
114126
abstract check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[]
115127

116128
/**
@@ -151,6 +163,19 @@ export abstract class LexerRule<TAutofixContext extends BaseAutofixContext = Bas
151163
get defaultConfig(): FullRuleConfig {
152164
return DEFAULT_RULE_CONFIG
153165
}
166+
167+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
168+
return {
169+
rule: this.ruleName,
170+
code: this.ruleName,
171+
source: "Herb Linter",
172+
message,
173+
location,
174+
autofixContext,
175+
severity,
176+
}
177+
}
178+
154179
abstract check(lexResult: LexResult, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[]
155180

156181
/**
@@ -215,6 +240,19 @@ export abstract class SourceRule<TAutofixContext extends BaseAutofixContext = Ba
215240
get defaultConfig(): FullRuleConfig {
216241
return DEFAULT_RULE_CONFIG
217242
}
243+
244+
protected createOffense(message: string, location: Location, autofixContext?: TAutofixContext, severity?: LintSeverity): UnboundLintOffense<TAutofixContext> {
245+
return {
246+
rule: this.ruleName,
247+
code: this.ruleName,
248+
source: "Herb Linter",
249+
message,
250+
location,
251+
autofixContext,
252+
severity,
253+
}
254+
}
255+
218256
abstract check(source: string, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[]
219257

220258
/**

javascript/packages/linter/test/__snapshots__/cli.test.ts.snap

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)