Skip to content

Commit fbe24be

Browse files
committed
Linter: Implement actionview-strict-locals-first-line rule
1 parent cf9ac07 commit fbe24be

File tree

8 files changed

+452
-8
lines changed

8 files changed

+452
-8
lines changed

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ 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
911
- [`actionview-no-void-element-content`](./actionview-no-void-element-content.md) - Disallow content arguments for void Action View elements
12+
- [`actionview-strict-locals-first-line`](./actionview-strict-locals-first-line.md) - Require strict locals on the first line of partials with a blank line after.
13+
14+
15+
#### ERB
16+
1017
- [`erb-comment-syntax`](./erb-comment-syntax.md) - Disallow Ruby comments immediately after ERB tags
1118
- [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
1219
- [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
@@ -36,12 +43,21 @@ This page contains documentation for all Herb Linter rules.
3643
- [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags
3744
- [`erb-right-trim`](./erb-right-trim.md) - Enforce consistent right-trimming syntax.
3845
- [`erb-strict-locals-comment-syntax`](./erb-strict-locals-comment-syntax.md) - Enforce strict locals comment syntax.
46+
- [`erb-strict-locals-required`](./erb-strict-locals-required.md) - Require strict locals in Rails partials.
47+
48+
49+
#### Herb
50+
3951
- [`herb-disable-comment-malformed`](./herb-disable-comment-malformed.md) - Detect malformed `herb:disable` comments.
4052
- [`herb-disable-comment-missing-rules`](./herb-disable-comment-missing-rules.md) - Require rule names in `herb:disable` comments.
4153
- [`herb-disable-comment-no-duplicate-rules`](./herb-disable-comment-no-duplicate-rules.md) - Disallow duplicate rule names in `herb:disable` comments.
4254
- [`herb-disable-comment-no-redundant-all`](./herb-disable-comment-no-redundant-all.md) - Disallow redundant use of `all` in `herb:disable` comments.
4355
- [`herb-disable-comment-unnecessary`](./herb-disable-comment-unnecessary.md) - Detect unnecessary `herb:disable` comments.
4456
- [`herb-disable-comment-valid-rule-name`](./herb-disable-comment-valid-rule-name.md) - Validate rule names in `herb:disable` comments.
57+
58+
59+
#### HTML
60+
4561
- [`html-allowed-script-type`](./html-allowed-script-type.md) - Restrict allowed `type` attributes for `<script>` tags
4662
- [`html-anchor-require-href`](./html-anchor-require-href.md) - Requires an href attribute on anchor tags
4763
- [`html-aria-attribute-must-be-valid`](./html-aria-attribute-must-be-valid.md) - Disallow invalid or unknown `aria-*` attributes.
@@ -77,11 +93,28 @@ This page contains documentation for all Herb Linter rules.
7793
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
7894
- [`html-require-script-nonce`](./html-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
7995
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
96+
97+
98+
#### Parser
99+
80100
- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
101+
102+
103+
#### Source
104+
81105
- [`source-indentation`](./source-indentation.md) - Indent with spaces instead of tabs.
106+
107+
108+
#### SVG
109+
82110
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
111+
112+
113+
#### Turbo
114+
83115
- [`turbo-permanent-require-id`](./turbo-permanent-require-id.md) - Require `id` attribute on elements with `data-turbo-permanent`
84116

117+
85118
## Contributing
86119

87120
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:** `actionview-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
@@ -3,6 +3,7 @@ import type { RuleClass } from "./types.js"
33
import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js"
44
import { ActionViewNoSilentRenderRule } from "./rules/actionview-no-silent-render.js"
55
import { ActionViewNoVoidElementContentRule } from "./rules/actionview-no-void-element-content.js"
6+
import { ActionViewStrictLocalsFirstLineRule } from "./rules/actionview-strict-locals-first-line.js"
67

78
import { ERBCommentSyntax } from "./rules/erb-comment-syntax.js";
89
import { ERBNoCaseNodeChildrenRule } from "./rules/erb-no-case-node-children.js"
@@ -93,6 +94,7 @@ export const rules: RuleClass[] = [
9394
ActionViewNoSilentHelperRule,
9495
ActionViewNoSilentRenderRule,
9596
ActionViewNoVoidElementContentRule,
97+
ActionViewStrictLocalsFirstLineRule,
9698

9799
ERBCommentSyntax,
98100
ERBNoCaseNodeChildrenRule,
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 { ParseResult, DocumentNode, ERBStrictLocalsNode } from "@herb-tools/core"
9+
import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"
10+
11+
class ActionViewStrictLocalsFirstLineVisitor extends BaseRuleVisitor {
12+
visitDocumentNode(node: DocumentNode) {
13+
const { children } = node
14+
15+
for (let i = 0; i < children.length; i++) {
16+
const child = children[i]
17+
if (!isERBStrictLocalsNode(child)) continue
18+
19+
const next = children[i + 1]
20+
if (!next) break
21+
22+
if (isHTMLTextNode(next)) {
23+
if (!next.content.startsWith("\n\n") && children[i + 2]) {
24+
this.addOffense(
25+
"Add a blank line after the strict locals declaration.",
26+
child.location
27+
)
28+
}
29+
} else {
30+
this.addOffense(
31+
"Add a blank line after the strict locals declaration.",
32+
child.location
33+
)
34+
}
35+
36+
break
37+
}
38+
39+
this.visitChildNodes(node)
40+
}
41+
42+
visitERBStrictLocalsNode(node: ERBStrictLocalsNode): void {
43+
if (isPartialFile(this.context.fileName) !== true) return
44+
45+
if (node.location.start.line !== 1) {
46+
this.addOffense(
47+
"Strict locals declaration must be on the first line of the partial.",
48+
node.location
49+
)
50+
}
51+
}
52+
}
53+
54+
export class ActionViewStrictLocalsFirstLineRule extends ParserRule {
55+
static autocorrectable = true
56+
static ruleName = "actionview-strict-locals-first-line"
57+
static introducedIn = this.version("unreleased")
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 ActionViewStrictLocalsFirstLineVisitor(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"
@@ -39,7 +39,7 @@ export class ERBStrictLocalsRequiredRule extends ParserRule {
3939

4040
if (visitor.foundStrictLocals) return []
4141

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

@@ -52,12 +52,7 @@ export class ERBStrictLocalsRequiredRule extends ParserRule {
5252
}
5353

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

6257
return result
6358
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from "./herb-disable-comment-base.js"
66
export * from "./actionview-no-silent-helper.js"
77
export * from "./actionview-no-silent-render.js"
88
export * from "./actionview-no-void-element-content.js"
9+
export * from "./actionview-strict-locals-first-line.js"
910

1011
export * from "./erb-comment-syntax.js"
1112
export * from "./erb-no-case-node-children.js"

0 commit comments

Comments
 (0)