Skip to content

Commit 3a1b52d

Browse files
committed
Linter: Implement erb-strict-locals-first-line rule
1 parent 2ec3a5d commit 3a1b52d

File tree

7 files changed

+446
-8
lines changed

7 files changed

+446
-8
lines changed

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ This page contains documentation for all Herb Linter rules.
44

55
## Available Rules
66

7+
#### Action View
8+
79
- [`actionview-no-silent-helper`](./actionview-no-silent-helper.md) - Disallow silent ERB tags for Action View helpers
810
- [`actionview-no-silent-render`](./actionview-no-silent-render.md) - Disallow calling `render` without outputting the result
11+
12+
#### ERB
13+
914
- [`erb-comment-syntax`](./erb-comment-syntax.md) - Disallow Ruby comments immediately after ERB tags
1015
- [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
1116
- [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
@@ -35,12 +40,22 @@ This page contains documentation for all Herb Linter rules.
3540
- [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags
3641
- [`erb-right-trim`](./erb-right-trim.md) - Enforce consistent right-trimming syntax.
3742
- [`erb-strict-locals-comment-syntax`](./erb-strict-locals-comment-syntax.md) - Enforce strict locals comment syntax.
43+
- [`erb-strict-locals-first-line`](./erb-strict-locals-first-line.md) - Require strict locals on the first line of partials with a blank line after.
44+
- [`erb-strict-locals-required`](./erb-strict-locals-required.md) - Require strict locals in Rails partials.
45+
46+
47+
#### Herb
48+
3849
- [`herb-disable-comment-malformed`](./herb-disable-comment-malformed.md) - Detect malformed `herb:disable` comments.
3950
- [`herb-disable-comment-missing-rules`](./herb-disable-comment-missing-rules.md) - Require rule names in `herb:disable` comments.
4051
- [`herb-disable-comment-no-duplicate-rules`](./herb-disable-comment-no-duplicate-rules.md) - Disallow duplicate rule names in `herb:disable` comments.
4152
- [`herb-disable-comment-no-redundant-all`](./herb-disable-comment-no-redundant-all.md) - Disallow redundant use of `all` in `herb:disable` comments.
4253
- [`herb-disable-comment-unnecessary`](./herb-disable-comment-unnecessary.md) - Detect unnecessary `herb:disable` comments.
4354
- [`herb-disable-comment-valid-rule-name`](./herb-disable-comment-valid-rule-name.md) - Validate rule names in `herb:disable` comments.
55+
56+
57+
#### HTML
58+
4459
- [`html-allowed-script-type`](./html-allowed-script-type.md) - Restrict allowed `type` attributes for `<script>` tags
4560
- [`html-anchor-require-href`](./html-anchor-require-href.md) - Requires an href attribute on anchor tags
4661
- [`html-aria-attribute-must-be-valid`](./html-aria-attribute-must-be-valid.md) - Disallow invalid or unknown `aria-*` attributes.
@@ -76,10 +91,23 @@ This page contains documentation for all Herb Linter rules.
7691
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
7792
- [`html-require-script-nonce`](./html-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
7893
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
94+
95+
96+
#### Parser
97+
7998
- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
99+
100+
101+
#### SVG
102+
80103
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
104+
105+
106+
#### Turbo
107+
81108
- [`turbo-permanent-require-id`](./turbo-permanent-require-id.md) - Require `id` attribute on elements with `data-turbo-permanent`
82109

110+
83111
## Contributing
84112

85113
To add a new linter rule you can scaffold a new rule by running:
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Linter Rule: Require strict locals on the first line with a blank line after
2+
3+
**Rule:** `erb-strict-locals-first-line`
4+
5+
## Description
6+
7+
Requires that the strict locals declaration:
8+
9+
1. Appears on the **first line** of a Rails partial template
10+
2. Is followed by a **blank line** before any content
11+
12+
A partial is any template whose filename begins with an underscore (e.g. `_card.html.erb`).
13+
14+
## Rationale
15+
16+
While Rails accepts strict locals declarations anywhere in a partial, placing them at the very top, followed by a blank line, makes the partial's expected locals immediately visible and visually separated from the template body. This mirrors conventions like `# frozen_string_literal: true` in Ruby files.
17+
18+
Enforcing this placement ensures that locals are the first thing you see when opening the file, that the partial's public API is clearly separated from its content, and consistent across all partials in the codebase.
19+
20+
## Examples
21+
22+
### ✅ Good
23+
24+
```erb [app/views/users/_card.html.erb]
25+
<%# locals: (user:) %>
26+
27+
<div class="user-card">
28+
<%= user.name %>
29+
</div>
30+
```
31+
32+
### 🚫 Bad
33+
34+
Strict locals not on the first line:
35+
36+
```erb [app/views/users/_card.html.erb]
37+
<div class="user-card">
38+
<%# locals: (user:) %>
39+
<%= user.name %>
40+
</div>
41+
```
42+
43+
Strict locals after a leading blank line:
44+
45+
```erb [app/views/users/_card.html.erb]
46+
47+
<%# locals: (user:) %>
48+
49+
<div class="user-card">
50+
<%= user.name %>
51+
</div>
52+
```
53+
54+
Strict locals on line 1 but no blank line before content:
55+
56+
```erb [app/views/users/_card.html.erb]
57+
<%# locals: (user:) %>
58+
<div class="user-card">
59+
<%= user.name %>
60+
</div>
61+
```
62+
63+
## References
64+
65+
- [Action View - Strict Locals](https://guides.rubyonrails.org/action_view_overview.html#strict-locals)

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newl
3333
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
3434
import { ERBRightTrimRule } from "./rules/erb-right-trim.js"
3535
import { ERBStrictLocalsCommentSyntaxRule } from "./rules/erb-strict-locals-comment-syntax.js"
36+
import { ERBStrictLocalsFirstLineRule } from "./rules/erb-strict-locals-first-line.js"
3637
import { ERBStrictLocalsRequiredRule } from "./rules/erb-strict-locals-required.js"
3738

3839
import { HerbDisableCommentMalformedRule } from "./rules/herb-disable-comment-malformed.js"
@@ -120,6 +121,7 @@ export const rules: RuleClass[] = [
120121
ERBRequireWhitespaceRule,
121122
ERBRightTrimRule,
122123
ERBStrictLocalsCommentSyntaxRule,
124+
ERBStrictLocalsFirstLineRule,
123125
ERBStrictLocalsRequiredRule,
124126

125127
HerbDisableCommentMalformedRule,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { BaseRuleVisitor } from "./rule-utils.js"
2+
import { ParserRule } from "../types.js"
3+
import { createLiteral } from "@herb-tools/core"
4+
5+
import { isERBStrictLocalsNode, isHTMLTextNode } from "@herb-tools/core"
6+
import { isPartialFile } from "./file-utils.js"
7+
8+
import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"
9+
import type { ParseResult, DocumentNode, ERBStrictLocalsNode, HTMLTextNode } from "@herb-tools/core"
10+
11+
class ERBStrictLocalsFirstLineVisitor extends BaseRuleVisitor {
12+
13+
visitDocumentNode(node: DocumentNode) {
14+
const { children } = node
15+
16+
for (let i = 0; i < children.length; i++) {
17+
const child = children[i]
18+
if (!isERBStrictLocalsNode(child)) continue
19+
20+
const next = children[i + 1]
21+
if (!next) break
22+
23+
if (isHTMLTextNode(next)) {
24+
if (!next.content.startsWith("\n\n") && children[i + 2]) {
25+
this.addOffense(
26+
"Add a blank line after the strict locals declaration.",
27+
child.location
28+
)
29+
}
30+
} else {
31+
this.addOffense(
32+
"Add a blank line after the strict locals declaration.",
33+
child.location
34+
)
35+
}
36+
37+
break
38+
}
39+
40+
this.visitChildNodes(node)
41+
}
42+
43+
visitERBStrictLocalsNode(node: ERBStrictLocalsNode): void {
44+
if (isPartialFile(this.context.fileName) !== true) return
45+
46+
if (node.location.start.line !== 1) {
47+
this.addOffense(
48+
"Strict locals declaration must be on the first line of the partial.",
49+
node.location
50+
)
51+
}
52+
}
53+
}
54+
55+
export class ERBStrictLocalsFirstLineRule extends ParserRule {
56+
static autocorrectable = true
57+
static ruleName = "erb-strict-locals-first-line"
58+
59+
get parserOptions() {
60+
return { strict_locals: true }
61+
}
62+
63+
get defaultConfig(): FullRuleConfig {
64+
return {
65+
enabled: false,
66+
severity: "error",
67+
}
68+
}
69+
70+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
71+
if (isPartialFile(context?.fileName) !== true) return []
72+
73+
const visitor = new ERBStrictLocalsFirstLineVisitor(this.ruleName, context)
74+
visitor.visit(result.value)
75+
76+
return visitor.offenses
77+
}
78+
79+
autofix(offense: LintOffense, result: ParseResult): ParseResult | null {
80+
const children = result.value.children
81+
82+
const index = children.findIndex(child =>
83+
child.location.start.line === offense.location.start.line &&
84+
child.location.start.column === offense.location.start.column
85+
)
86+
87+
if (index === -1) return null
88+
89+
if (offense.location.start.line === 1) {
90+
const next = children[index + 1]
91+
92+
if (isHTMLTextNode(next)) {
93+
children.splice(index + 1, 1, createLiteral("\n\n"))
94+
} else {
95+
children.splice(index + 1, 0, createLiteral("\n\n"))
96+
}
97+
} else {
98+
const [node] = children.splice(index, 1)
99+
100+
if (index > 0) {
101+
const previous = children[index - 1]
102+
103+
if (isHTMLTextNode(previous) && /^\s*$/.test(previous.content)) {
104+
children.splice(index - 1, 1)
105+
}
106+
}
107+
108+
const firstChild = children[0]
109+
110+
if (!firstChild || !isHTMLTextNode(firstChild) || !firstChild.content.startsWith("\n\n")) {
111+
children.unshift(createLiteral("\n\n"))
112+
}
113+
114+
children.unshift(node)
115+
}
116+
117+
return result
118+
}
119+
}

javascript/packages/linter/src/rules/erb-strict-locals-required.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ParserRule } from "../types.js"
2-
import { Location, ERBStrictLocalsNode, LiteralNode } from "@herb-tools/core"
2+
import { Location, ERBStrictLocalsNode, createLiteral } from "@herb-tools/core"
33
import { BaseRuleVisitor } from "./rule-utils.js"
44

55
import { isPartialFile } from "./file-utils.js"
@@ -38,7 +38,7 @@ export class ERBStrictLocalsRequiredRule extends ParserRule {
3838

3939
if (visitor.foundStrictLocals) return []
4040

41-
const document = result.value as DocumentNode
41+
const document = result.value
4242
const firstChild = document.children[0]
4343
const end = firstChild ? firstChild.location.end : Location.zero.end
4444

@@ -51,12 +51,7 @@ export class ERBStrictLocalsRequiredRule extends ParserRule {
5151
}
5252

5353
autofix(_offense: LintOffense, result: ParseResult): ParseResult | null {
54-
(result.value.children as unknown[]).unshift(LiteralNode.from({
55-
type: "AST_LITERAL_NODE",
56-
location: Location.zero,
57-
errors: [],
58-
content: "<%# locals: () %>\n\n",
59-
}))
54+
result.value.children.unshift(createLiteral("<%# locals: () %>\n\n"))
6055

6156
return result
6257
}

0 commit comments

Comments
 (0)