Skip to content

Commit f133bd1

Browse files
authored
Linter: Implement actionview-no-void-element-content rule (#1456)
This pull request implements a new `actionview-no-void-element-content` based on the new error introduced in #1454. Because the `parser-no-errors` rule doesn't define `action_view_helpers: true` it doesn't pick up and report the new `VoidElementContentError`. So this rule parses the document with `action_view_helpers: true` and surfaces the `VoidElementContentError` errors that way: <img width="2227" height="548" alt="CleanShot 2026-03-22 at 13 47 02@2x" src="https://github.com/user-attachments/assets/ee7d761e-838c-46cb-bccc-0c26f0ebbda0" /> Ideally in the future we could infer `action_view_helpers: true` somehow and pass it along to the `parser-no-errors` rule. Either by checking if you are running this in a ActionView-enabled project, or by having a setting in `config.yml` like `framework: "actionview"` or similar (related #1359). Additionally, it improves the `VoidElementContentError` error message and the location reporting.
1 parent 81ae2ba commit f133bd1

16 files changed

+246
-50
lines changed

config.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,15 +460,18 @@ errors:
460460

461461
- name: VoidElementContentError
462462
message:
463-
template: "Void element `%s` cannot have content. `%s` does not accept positional arguments."
463+
template: "Void element `%s` cannot have content. `%s` does not accept a positional argument for content."
464464
arguments:
465465
- tag_name->value
466-
- tag_name->value
466+
- helper_name
467467

468468
fields:
469469
- name: tag_name
470470
type: token
471471

472+
- name: helper_name
473+
type: string
474+
472475
- name: DotNotationCasingError
473476
message:
474477
template: "Dot-notation component tags require the first segment to start with an uppercase letter. `%s` does not start with an uppercase letter."

javascript/packages/linter/src/linter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,10 +419,10 @@ export class Linter {
419419
const parserOptions = this.isParserRuleClass(ruleClass) ? (rule as ParserRule).parserOptions : {}
420420
const parseResult = this.parseCache.get(source, parserOptions)
421421

422-
// Skip parser rules whose parse result has errors (parser-no-errors handled above)
422+
// Skip parser rules whose parse result has errors (unless the rule consumes parser errors)
423423
// Skip lexer/source rules when the default parse has errors
424424
if (this.isParserRuleClass(ruleClass)) {
425-
if (parseResult.recursiveErrors().length > 0) continue
425+
if (parseResult.recursiveErrors().length > 0 && !ruleClass.consumesParserErrors) continue
426426
} else if (hasParserErrors) {
427427
continue
428428
}

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { RuleClass } from "./types.js"
22

33
import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js"
44
import { ActionViewNoSilentRenderRule } from "./rules/actionview-no-silent-render.js"
5+
import { ActionViewNoVoidElementContentRule } from "./rules/actionview-no-void-element-content.js"
56

67
import { ERBCommentSyntax } from "./rules/erb-comment-syntax.js";
78
import { ERBNoCaseNodeChildrenRule } from "./rules/erb-no-case-node-children.js"
@@ -89,6 +90,7 @@ import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.
8990
export const rules: RuleClass[] = [
9091
ActionViewNoSilentHelperRule,
9192
ActionViewNoSilentRenderRule,
93+
ActionViewNoVoidElementContentRule,
9294

9395
ERBCommentSyntax,
9496
ERBNoCaseNodeChildrenRule,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ParserRule } from "../types.js"
2+
3+
import type { LintOffense, FullRuleConfig } from "../types.js"
4+
import type { ParseResult, ParserOptions } from "@herb-tools/core"
5+
6+
export class ActionViewNoVoidElementContentRule extends ParserRule {
7+
static ruleName = "actionview-no-void-element-content"
8+
static introducedIn = this.version("unreleased")
9+
static consumesParserErrors = true
10+
11+
get defaultConfig(): FullRuleConfig {
12+
return {
13+
enabled: true,
14+
severity: "error"
15+
}
16+
}
17+
18+
get parserOptions(): Partial<ParserOptions> {
19+
return {
20+
action_view_helpers: true,
21+
}
22+
}
23+
24+
check(result: ParseResult): LintOffense[] {
25+
return result.recursiveErrors()
26+
.filter(error => error.type === "VOID_ELEMENT_CONTENT_ERROR")
27+
.map(error => this.herbErrorToLintOffense(error))
28+
}
29+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from "./herb-disable-comment-base.js"
55

66
export * from "./actionview-no-silent-helper.js"
77
export * from "./actionview-no-silent-render.js"
8+
export * from "./actionview-no-void-element-content.js"
89

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

javascript/packages/linter/src/rules/parser-no-errors.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,10 @@ export class ParserNoErrorsRule extends ParserRule {
2020
)
2121
}
2222

23-
private herbErrorToLintOffense(error: HerbError): LintOffense {
23+
protected herbErrorToLintOffense(error: HerbError): LintOffense {
2424
return {
25+
...super.herbErrorToLintOffense(error),
2526
message: `${error.message} (\`${error.type}\`)`,
26-
location: error.location,
27-
severity: error.severity,
28-
rule: this.ruleName,
29-
code: this.ruleName,
30-
source: "linter"
3127
}
3228
}
3329
}

javascript/packages/linter/src/types.ts

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

3-
import type { DiagnosticTag } from "@herb-tools/core"
3+
import type { DiagnosticTag, HerbError } from "@herb-tools/core"
44
import type { rules } from "./rules.js"
55
import type { Node, ParserOptions } from "@herb-tools/core"
66
import type { RuleConfig } from "@herb-tools/config"
@@ -106,6 +106,8 @@ export abstract class ParserRule<TAutofixContext extends BaseAutofixContext = Ba
106106
static unsafeAutocorrectable = false
107107
/** Indicates whether the source should be re-indented after autofix. Defaults to false. */
108108
static reindentAfterAutofix = false
109+
/** Indicates whether this rule consumes parser errors (like parser-no-errors). Rules with this flag are not skipped when parse results contain errors. */
110+
static consumesParserErrors = false
109111

110112
get ruleName(): string {
111113
return (this.constructor as typeof ParserRule).ruleName
@@ -132,6 +134,17 @@ export abstract class ParserRule<TAutofixContext extends BaseAutofixContext = Ba
132134
}
133135
}
134136

137+
protected herbErrorToLintOffense(error: HerbError): LintOffense {
138+
return {
139+
message: error.message,
140+
location: error.location,
141+
severity: error.severity,
142+
rule: this.ruleName,
143+
code: this.ruleName,
144+
source: "linter"
145+
}
146+
}
147+
135148
abstract check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[]
136149

137150
/**
@@ -314,6 +327,7 @@ export type ParserRuleClass = (new () => ParserRule) & {
314327
ruleName: string
315328
introducedIn: RuleVersion
316329
reindentAfterAutofix?: boolean
330+
consumesParserErrors?: boolean
317331
}
318332

319333
export type LexerRuleClass = LexerRuleConstructor

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

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

javascript/packages/linter/test/helpers/linter-test-helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export function createLinterTest(rules: RuleClass | RuleClass[], configOverride?
7575
const ruleClasses = Array.isArray(rules) ? rules : [rules]
7676
const primaryRuleClass = ruleClasses[0]
7777
const ruleInstance = new primaryRuleClass()
78-
const isParserNoErrorsRule = primaryRuleClass.ruleName === "parser-no-errors"
78+
const isParserNoErrorsRule = primaryRuleClass.ruleName === "parser-no-errors" || ('consumesParserErrors' in primaryRuleClass && primaryRuleClass.consumesParserErrors)
7979
const ruleParserOptions = ruleInstance instanceof ParserRule ? ruleInstance.parserOptions : {}
8080
const parseCache = new ParseCache(Herb)
8181
const ruleConfigOverride = configOverride

0 commit comments

Comments
 (0)