@@ -2,16 +2,9 @@ import { BaseRuleVisitor } from "./rule-utils.js"
22import { ParserRule } from "../types.js"
33
44import { isPartialFile } from "./file-utils.js"
5- import { hasBalancedParentheses , splitByTopLevelComma } from "./string-utils.js"
65
76import type { UnboundLintOffense , LintContext , FullRuleConfig } from "../types.js"
8- import type { ParseResult , ERBContentNode } from "@herb-tools/core"
9-
10- export const STRICT_LOCALS_PATTERN = / ^ l o c a l s : \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
169function 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 ( / ^ l o c a l s ? \b ( .* ) $ / )
28-
29- return match ? match [ 1 ] : null
30- }
31-
3219function looksLikeLocalsDeclaration ( content : string ) : boolean {
3320 return / ^ l o c a l s ? \b / . test ( content ) && / [ ( : ) ] / . test ( content )
3421}
3522
36- function hasLocalsLikeSyntax ( remainder : string ) : boolean {
37- return / [ ( : ) ] / . test ( remainder )
38- }
39-
4023function detectLocalsWithoutColon ( content : string ) : boolean {
4124 return / ^ l o c a l s ? \( / . test ( content )
4225}
4326
4427function detectSingularLocal ( content : string ) : boolean {
45- return content . startsWith ( ' local:' )
28+ return content . startsWith ( " local:" )
4629}
4730
4831function detectMissingColonBeforeParens ( content : string ) : boolean {
4932 return / ^ l o c a l s \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 / ^ l o c a l s : \s * [ ^ ( ] / . test ( content )
58- }
59-
60- function detectEmptyLocalsWithoutParens ( content : string ) : boolean {
61- return / ^ l o c a l s : \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 ( / ^ l o c a l s ? \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 ( / ^ l o c a l s : \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
266109export 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