Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ This page contains documentation for all Herb Linter rules.
- [`erb-no-instance-variables-in-partials`](./erb-no-instance-variables-in-partials.md) - Disallow instance variables in partials
- [`erb-no-interpolated-class-names`](./erb-no-interpolated-class-names.md) - Disallow ERB interpolation inside CSS class names
- [`erb-no-javascript-tag-helper`](./erb-no-javascript-tag-helper.md) - Disallow `javascript_tag` helper
- [`erb-no-multiple-statements`](./erb-no-multiple-statements.md) - Disallow multiple Ruby statements in a single-line ERB tag
- [`erb-no-output-control-flow`](./erb-no-output-control-flow.md) - Prevents outputting control flow blocks
- [`erb-no-output-in-attribute-name`](./erb-no-output-in-attribute-name.md) - Disallow ERB output in attribute names
- [`erb-no-output-in-attribute-position`](./erb-no-output-in-attribute-position.md) - Disallow ERB output in attribute position
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Linter Rule: Disallow multiple Ruby statements in a single-line ERB tag

**Rule:** `erb-no-multiple-statements`

## Description

Disallow multiple Ruby statements separated by semicolons within a single-line ERB tag. Each ERB tag on a single line should contain at most one Ruby statement.

## Rationale

Multiple Ruby statements on a single line in an ERB tag reduce readability and make templates harder to maintain. Splitting statements into separate ERB tags makes each statement easier to understand at a glance.

This rule only applies to single-line ERB tags. Multi-line ERB tags are not checked, as they naturally provide visual separation between statements.

## Examples

### ✅ Good

```erb
<% user = User.find(1) %>
<% post = user.posts.first %>
```

```erb
<%= user.name %>
```

```erb
<%
user = User.find(1)
post = user.posts.first
%>
```

### 🚫 Bad

```erb
<% user = User.find(1); post = user.posts.first %>
```

```erb
<%= user = User.find(1); user.name %>
```

```erb
<% a = 1; b = 2; c = 3 %>
```

## References

- [Ruby Style Guide - Semicolons](https://rubystyle.guide/#no-semicolon)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ERBNoInlineCaseConditionsRule } from "./rules/erb-no-inline-case-condit
import { ERBNoInstanceVariablesInPartialsRule } from "./rules/erb-no-instance-variables-in-partials.js"
import { ERBNoInterpolatedClassNamesRule } from "./rules/erb-no-interpolated-class-names.js"
import { ERBNoJavascriptTagHelperRule } from "./rules/erb-no-javascript-tag-helper.js"
import { ERBNoMultipleStatementsRule } from "./rules/erb-no-multiple-statements.js"
import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
import { ERBNoOutputInAttributeNameRule } from "./rules/erb-no-output-in-attribute-name.js"
import { ERBNoOutputInAttributePositionRule } from "./rules/erb-no-output-in-attribute-position.js"
Expand Down Expand Up @@ -128,6 +129,7 @@ export const rules: RuleClass[] = [
ERBNoInstanceVariablesInPartialsRule,
ERBNoInterpolatedClassNamesRule,
ERBNoJavascriptTagHelperRule,
ERBNoMultipleStatementsRule,
ERBNoOutputControlFlowRule,
ERBNoOutputInAttributeNameRule,
ERBNoOutputInAttributePositionRule,
Expand Down
86 changes: 86 additions & 0 deletions javascript/packages/linter/src/rules/erb-no-multiple-statements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { ParserRule } from "../types.js"
import { BaseRuleVisitor } from "./rule-utils.js"

import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
import type { ParseResult, ERBContentNode, ParserOptions, PrismNode } from "@herb-tools/core"

function collectStatements(programNode: PrismNode): PrismNode[] {
const statements = programNode?.statements?.body

if (!Array.isArray(statements)) return []

return statements
}

class NoMultipleStatementsVisitor extends BaseRuleVisitor {
private readonly statements: PrismNode[]

constructor(ruleName: string, context: Partial<LintContext> | undefined, statements: PrismNode[]) {
super(ruleName, context)

this.statements = statements
}

visitERBContentNode(node: ERBContentNode): void {
const startLine = node.location.start.line
const endLine = node.location.end.line
if (startLine !== endLine) return

const tagOpening = node.tag_opening?.value
if (tagOpening === "<%#") return

const contentRange = node.content?.range
if (!contentRange) return

const rangeStart = contentRange.start
const rangeEnd = contentRange.end

let statementCount = 0

for (const statement of this.statements) {
const statementOffset = statement.location.startOffset

if (statementOffset >= rangeStart && statementOffset < rangeEnd) {
statementCount++
}
}

if (statementCount <= 1) return

this.addOffense(
`Avoid multiple Ruby statements in a single-line ERB tag. Split each statement into its own ERB tag for better readability.`,
node.location,
)
}
}

export class ERBNoMultipleStatementsRule extends ParserRule {
static ruleName = "erb-no-multiple-statements"
static introducedIn = this.version("unreleased")

get defaultConfig(): FullRuleConfig {
return {
enabled: true,
severity: "warning",
}
}

get parserOptions(): Partial<ParserOptions> {
return {
prism_nodes: true,
prism_program: true,
}
}

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
const documentPrismNode = result.value.prismNode
if (!documentPrismNode) return []

const statements = collectStatements(documentPrismNode)
if (statements.length === 0) return []

const visitor = new NoMultipleStatementsVisitor(this.ruleName, context, statements)
visitor.visit(result.value)
return visitor.offenses
}
}
1 change: 1 addition & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export * from "./erb-no-extra-whitespace-inside-tags.js"
export * from "./erb-no-inline-case-conditions.js"
export * from "./erb-no-instance-variables-in-partials.js"
export * from "./erb-no-javascript-tag-helper.js"
export * from "./erb-no-multiple-statements.js"
export * from "./erb-no-output-control-flow.js"
export * from "./erb-no-output-in-attribute-name.js"
export * from "./erb-no-output-in-attribute-position.js"
Expand Down
48 changes: 24 additions & 24 deletions javascript/packages/linter/test/__snapshots__/cli.test.ts.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading