Skip to content

Commit 2236f9d

Browse files
authored
Linter: Rework strict locals linter rules with new node (#1430)
This pull request reworks and simplifies the `erb-strict-locals-comment-syntax` and `erb-strict-locals-required` linter rules to utilize the new `ERBStrictLocalsNode` introduced in #1424
1 parent 5984eb8 commit 2236f9d

File tree

18 files changed

+477
-312
lines changed

18 files changed

+477
-312
lines changed

javascript/packages/core/src/token.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ export class Token {
2323
)
2424
}
2525

26+
static synthetic(value: string, type: string = "SYNTETHIC") {
27+
return new Token(
28+
value,
29+
Range.zero,
30+
Location.zero,
31+
type
32+
)
33+
}
34+
2635
constructor(value: string, range: Range, location: Location, type: string) {
2736
this.value = value
2837
this.range = range

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

Lines changed: 40 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,9 @@ import { BaseRuleVisitor } from "./rule-utils.js"
22
import { ParserRule } from "../types.js"
33

44
import { isPartialFile } from "./file-utils.js"
5-
import { hasBalancedParentheses, splitByTopLevelComma } from "./string-utils.js"
65

76
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
8-
import type { ParseResult, ERBContentNode } from "@herb-tools/core"
9-
10-
export const STRICT_LOCALS_PATTERN = /^locals:\s+\(.*\)\s*$/s
11-
12-
function isValidStrictLocalsFormat(content: string): boolean {
13-
return STRICT_LOCALS_PATTERN.test(content)
14-
}
7+
import type { ParseResult, ERBContentNode, ERBStrictLocalsNode } from "@herb-tools/core"
158

169
function extractERBCommentContent(content: string): string {
1710
return content.trim()
@@ -23,249 +16,103 @@ function extractRubyCommentContent(content: string): string | null {
2316
return match ? match[1].trim() : null
2417
}
2518

26-
function extractLocalsRemainder(content: string): string | null {
27-
const match = content.match(/^locals?\b(.*)$/)
28-
29-
return match ? match[1] : null
30-
}
31-
3219
function looksLikeLocalsDeclaration(content: string): boolean {
3320
return /^locals?\b/.test(content) && /[(:)]/.test(content)
3421
}
3522

36-
function hasLocalsLikeSyntax(remainder: string): boolean {
37-
return /[(:)]/.test(remainder)
38-
}
39-
4023
function detectLocalsWithoutColon(content: string): boolean {
4124
return /^locals?\(/.test(content)
4225
}
4326

4427
function detectSingularLocal(content: string): boolean {
45-
return content.startsWith('local:')
28+
return content.startsWith("local:")
4629
}
4730

4831
function detectMissingColonBeforeParens(content: string): boolean {
4932
return /^locals\s+\(/.test(content)
5033
}
5134

52-
function detectMissingSpaceAfterColon(content: string): boolean {
53-
return content.startsWith('locals:(')
54-
}
55-
56-
function detectMissingParentheses(content: string): boolean {
57-
return /^locals:\s*[^(]/.test(content)
58-
}
59-
60-
function detectEmptyLocalsWithoutParens(content: string): boolean {
61-
return /^locals:\s*$/.test(content)
62-
}
63-
64-
function validateCommaUsage(inner: string): string | null {
65-
if (inner.startsWith(",") || inner.endsWith(",") || /,,/.test(inner)) {
66-
return "Unexpected comma in `locals:` parameters."
67-
}
68-
69-
return null
70-
}
71-
72-
function validateBlockArgument(param: string): string | null {
73-
if (param.startsWith("&")) {
74-
return `Block argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`
75-
}
76-
77-
return null
78-
}
79-
80-
function validateSplatArgument(param: string): string | null {
81-
if (param.startsWith("*") && !param.startsWith("**")) {
82-
return `Splat argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`
83-
}
84-
85-
return null
86-
}
35+
class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
36+
visitERBStrictLocalsNode(node: ERBStrictLocalsNode): void {
37+
const isPartial = isPartialFile(this.context.fileName)
8738

88-
function validateDoubleSplatArgument(param: string): string | null {
89-
if (param.startsWith("**")) {
90-
if (/^\*\*\w+$/.test(param)) {
91-
return null // Valid double-splat
39+
if (isPartial === false) {
40+
this.addOffense(
41+
"Strict locals (`locals:`) only work in partials (files starting with `_`). This declaration will be ignored.",
42+
node.location
43+
)
9244
}
9345

94-
return `Invalid double-splat syntax \`${param}\`. Use \`**name\` format (e.g., \`**attributes\`).`
95-
}
46+
if (node.errors.length > 0) return
9647

97-
return null
98-
}
48+
const content = node.content?.value ?? ""
49+
const trimmed = content.trim()
50+
const afterLocals = trimmed.slice("locals:".length)
9951

100-
function validateKeywordArgument(param: string): string | null {
101-
if (!/^\w+:\s*/.test(param)) {
102-
if (/^\w+$/.test(param)) {
103-
return `Positional argument \`${param}\` is not allowed. Use keyword argument format: \`${param}:\`.`
52+
if (afterLocals.length > 0 && afterLocals[0] !== " ") {
53+
this.addOffense(
54+
"Missing space after `locals:`. Rails Strict Locals require a space after the colon: `<%# locals: (...) %>`.",
55+
node.location
56+
)
10457
}
105-
106-
return `Invalid parameter \`${param}\`. Use keyword argument format: \`name:\` or \`name: default\`.`
107-
}
108-
109-
return null
110-
}
111-
112-
function validateParameter(param: string): string | null {
113-
const trimmed = param.trim()
114-
115-
if (!trimmed) return null
116-
117-
return (
118-
validateBlockArgument(trimmed) ||
119-
validateSplatArgument(trimmed) ||
120-
validateDoubleSplatArgument(trimmed) ||
121-
(trimmed.startsWith("**") ? null : validateKeywordArgument(trimmed))
122-
)
123-
}
124-
125-
function validateLocalsSignature(paramsContent: string): string | null {
126-
const match = paramsContent.match(/^\s*\(([\s\S]*)\)\s*$/)
127-
if (!match) return null
128-
129-
const inner = match[1].trim()
130-
if (!inner) return null // Empty locals is valid: locals: ()
131-
132-
const commaError = validateCommaUsage(inner)
133-
if (commaError) return commaError
134-
135-
const params = splitByTopLevelComma(inner)
136-
137-
for (const param of params) {
138-
const error = validateParameter(param)
139-
if (error) return error
14058
}
14159

142-
return null
143-
}
144-
145-
class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
146-
private seenStrictLocalsComment: boolean = false
147-
private firstStrictLocalsLocation: { line: number; column: number } | null = null
148-
14960
visitERBContentNode(node: ERBContentNode): void {
15061
const openingTag = node.tag_opening?.value
15162
const content = node.content?.value
15263

15364
if (!content) return
15465

155-
const commentContent = this.extractCommentContent(openingTag, content, node)
156-
if (!commentContent) return
157-
158-
const remainder = extractLocalsRemainder(commentContent)
159-
if (!remainder || !hasLocalsLikeSyntax(remainder)) return
160-
161-
this.validateLocalsComment(commentContent, node)
162-
}
163-
164-
private extractCommentContent(openingTag: string | undefined, content: string, node: ERBContentNode): string | null {
165-
if (openingTag === "<%#") {
166-
return extractERBCommentContent(content)
167-
}
168-
16966
if (openingTag === "<%" || openingTag === "<%-") {
17067
const rubyComment = extractRubyCommentContent(content)
17168

17269
if (rubyComment && looksLikeLocalsDeclaration(rubyComment)) {
173-
this.addOffense(`Use \`<%#\` instead of \`${openingTag} #\` for strict locals comments. Only ERB comment syntax is recognized by Rails.`, node.location)
70+
this.addOffense(
71+
`Use \`<%#\` instead of \`${openingTag} #\` for strict locals comments. Only ERB comment syntax is recognized by Rails.`,
72+
node.location
73+
)
17474
}
175-
}
17675

177-
return null
178-
}
179-
180-
private validateLocalsComment(commentContent: string, node: ERBContentNode): void {
181-
this.checkPartialFile(node)
182-
183-
if (!hasBalancedParentheses(commentContent)) {
184-
this.addOffense("Unbalanced parentheses in `locals:` comment. Ensure all opening parentheses have matching closing parentheses.", node.location)
18576
return
18677
}
18778

188-
if (isValidStrictLocalsFormat(commentContent)) {
189-
this.handleValidFormat(commentContent, node)
190-
return
191-
}
79+
if (openingTag !== "<%#") return
19280

193-
this.handleInvalidFormat(commentContent, node)
194-
}
81+
const commentContent = extractERBCommentContent(content)
82+
const remainder = commentContent.match(/^locals?\b(.*)/s)?.[1]
19583

196-
private checkPartialFile(node: ERBContentNode): void {
197-
const isPartial = isPartialFile(this.context.fileName)
84+
if (!remainder || !/[(:)]/.test(remainder)) return
19885

199-
if (isPartial === false) {
200-
this.addOffense("Strict locals (`locals:`) only work in partials (files starting with `_`). This declaration will be ignored.", node.location)
86+
if (detectSingularLocal(commentContent)) {
87+
this.addOffense("Use `locals:` (plural), not `local:`.", node.location)
88+
return
20189
}
202-
}
20390

204-
private handleValidFormat(commentContent: string, node: ERBContentNode): void {
205-
if (this.seenStrictLocalsComment) {
91+
if (detectLocalsWithoutColon(commentContent)) {
20692
this.addOffense(
207-
`Duplicate \`locals:\` declaration. Only one \`locals:\` comment is allowed per partial (first declaration at line ${this.firstStrictLocalsLocation?.line}).`,
93+
"Use `locals:` with a colon, not `locals()`. Correct format: `<%# locals: (...) %>`.",
20894
node.location
20995
)
210-
211-
return
212-
}
213-
214-
this.seenStrictLocalsComment = true
215-
this.firstStrictLocalsLocation = {
216-
line: node.location.start.line,
217-
column: node.location.start.column
218-
}
219-
220-
const paramsMatch = commentContent.match(/^locals:\s*(\([\s\S]*\))\s*$/)
221-
222-
if (paramsMatch) {
223-
const error = validateLocalsSignature(paramsMatch[1])
224-
225-
if (error) {
226-
this.addOffense(error, node.location)
227-
}
228-
}
229-
}
230-
231-
private handleInvalidFormat(commentContent: string, node: ERBContentNode): void {
232-
if (detectLocalsWithoutColon(commentContent)) {
233-
this.addOffense("Use `locals:` with a colon, not `locals()`. Correct format: `<%# locals: (...) %>`.", node.location)
234-
return
235-
}
236-
237-
if (detectSingularLocal(commentContent)) {
238-
this.addOffense("Use `locals:` (plural), not `local:`.", node.location)
23996
return
24097
}
24198

24299
if (detectMissingColonBeforeParens(commentContent)) {
243-
this.addOffense("Use `locals:` with a colon before the parentheses, not `locals (`.", node.location)
244-
return
245-
}
246-
247-
if (detectMissingSpaceAfterColon(commentContent)) {
248-
this.addOffense("Missing space after `locals:`. Rails Strict Locals require a space after the colon: `<%# locals: (...) %>`.", node.location)
249-
return
250-
}
251-
252-
if (detectMissingParentheses(commentContent)) {
253-
this.addOffense("Wrap parameters in parentheses: `locals: (name:)` or `locals: (name: default)`.", node.location)
254-
return
255-
}
256-
257-
if (detectEmptyLocalsWithoutParens(commentContent)) {
258-
this.addOffense("Add parameters after `locals:`. Use `locals: (name:)` or `locals: ()` for no locals.", node.location)
100+
this.addOffense(
101+
"Use `locals:` with a colon before the parentheses, not `locals (`.",
102+
node.location
103+
)
259104
return
260105
}
261-
262-
this.addOffense("Invalid `locals:` syntax. Use format: `locals: (name:, option: default)`.", node.location)
263106
}
264107
}
265108

266109
export class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
267110
static ruleName = "erb-strict-locals-comment-syntax"
268111

112+
get parserOptions() {
113+
return { strict_locals: true }
114+
}
115+
269116
get defaultConfig(): FullRuleConfig {
270117
return {
271118
enabled: true,

0 commit comments

Comments
 (0)