Skip to content
Merged
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 @@ -47,6 +47,7 @@ This page contains documentation for all Herb Linter rules.
- [`erb-no-unsafe-js-attribute`](./erb-no-unsafe-js-attribute.md) - Disallow unsafe ERB output in JavaScript attributes
- [`erb-no-unsafe-raw`](./erb-no-unsafe-raw.md) - Disallow `raw()` and `.html_safe` in ERB output
- [`erb-no-unsafe-script-interpolation`](./erb-no-unsafe-script-interpolation.md) - Disallow unsafe ERB output inside `<script>` tags
- [`erb-no-unused-expressions`](./erb-no-unused-expressions.md) - Disallow unused expressions in silent ERB tags
- [`erb-no-unused-literals`](./erb-no-unused-literals.md) - Disallow Ruby literals in ERB without output
- [`erb-prefer-image-tag-helper`](./erb-prefer-image-tag-helper.md) - Prefer `image_tag` helper over `<img>` with ERB expressions
- [`erb-require-trailing-newline`](./erb-require-trailing-newline.md) - Enforces that all HTML+ERB template files end with exactly one trailing newline character.
Expand Down
73 changes: 73 additions & 0 deletions javascript/packages/linter/docs/rules/erb-no-unused-expressions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Linter Rule: Disallow unused expressions in silent ERB tags

**Rule:** `erb-no-unused-expressions`

## Description

Disallow expressions inside silent ERB tags (`<% %>`) whose return values are discarded. Writing expressions like `<% @user.name %>` or `<% helper_method(arg) %>` evaluates the expression but discards the result, making the line functionally useless.

This rule detects and warns about silent ERB tags containing:

- Method calls without blocks
- Instance variable reads (`@user`)
- Class variable reads (`@@count`)
- Global variable reads (`$global`)
- Constant reads (`CONSTANT`, `Foo::Bar`)

## Rationale

These expressions are evaluated but unused, they don't produce output (not `<%= ... %>`), and they don't perform meaningful side effects (no assignments, no blocks, no control flow). They're likely a mistake where the developer meant to use an output tag (`<%= ... %>`) instead, or they're leftover from refactoring.

## Examples

### ✅ Good

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

```erb
<%= helper_method(arg) %>
```

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

```erb
<% if logged_in? %>
<p>Welcome!</p>
<% end %>
```

```erb
<% @users.each do |user| %>
<%= user.name %>
<% end %>
```

### 🚫 Bad

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

```erb
<% helper_method(arg) %>
```

```erb
<% foo.bar.baz %>
```

```erb
<% @user %>
```

```erb
<% CONSTANT %>
```

```erb
<% User.count %>
```
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ERBNoTrailingWhitespaceRule } from "./rules/erb-no-trailing-whitespace.
import { ERBNoUnsafeJSAttributeRule } from "./rules/erb-no-unsafe-js-attribute.js"
import { ERBNoUnsafeRawRule } from "./rules/erb-no-unsafe-raw.js"
import { ERBNoUnsafeScriptInterpolationRule } from "./rules/erb-no-unsafe-script-interpolation.js"
import { ERBNoUnusedExpressionsRule } from "./rules/erb-no-unused-expressions.js"
import { ERBNoUnusedLiteralsRule } from "./rules/erb-no-unused-literals.js"
import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newline.js"
Expand Down Expand Up @@ -131,6 +132,7 @@ export const rules: RuleClass[] = [
ERBNoOutputInAttributePositionRule,
ERBNoRawOutputInAttributeValueRule,
ERBNoSilentStatementRule,
ERBNoUnusedExpressionsRule,
ERBNoUnusedLiteralsRule,
ERBNoSilentTagInAttributeNameRule,
ERBNoStatementInScriptRule,
Expand Down
128 changes: 128 additions & 0 deletions javascript/packages/linter/src/rules/erb-no-unused-expressions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { ParserRule } from "../types.js"
import { PrismVisitor } from "@herb-tools/core"
import { BaseRuleVisitor } from "./rule-utils.js"

import { isERBOutputNode } from "@herb-tools/core"
import { isAssignmentNode, isDebugOutputCall } from "./prism-rule-utils.js"
import { locationFromOffset } from "./rule-utils.js"

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

const MUTATION_METHODS = new Set([
"<<",
"[]=",
"push",
"append",
"prepend",
"pop",
"shift",
"unshift",
"delete",
"clear",
"replace",
"insert",
"concat",
])

class UnusedExpressionCollector extends PrismVisitor {
public readonly expressions: PrismNode[] = []

override visit(node: PrismNode): void {
if (!node) return

const type: string = node.constructor?.name ?? ""

if (type === "ProgramNode" || type === "StatementsNode") {
super.visit(node)
return
}

if (isAssignmentNode(node)) return

if (this.isUnusedExpression(node, type)) {
this.expressions.push(node)
}
}

private isMutationCall(node: PrismNode): boolean {
if (node.name.endsWith("!")) return true

return MUTATION_METHODS.has(node.name)
}

private isUnusedExpression(node: PrismNode, type: string): boolean {
if (type === "CallNode") {
if (node.block) return false
if (this.isMutationCall(node)) return false
if (isDebugOutputCall(node)) return false

return true
}

return (
type === "InstanceVariableReadNode" ||
type === "ClassVariableReadNode" ||
type === "GlobalVariableReadNode" ||
type === "LocalVariableReadNode" ||
type === "ConstantReadNode" ||
type === "ConstantPathNode"
)
}
}

class ERBNoUnusedExpressionsVisitor extends BaseRuleVisitor {
visitERBContentNode(node: ERBContentNode): void {
if (isERBOutputNode(node)) return

const prismNode = node.prismNode
if (!prismNode) return

const source = node.source
if (!source) return

const collector = new UnusedExpressionCollector()
collector.visit(prismNode)

for (const expression of collector.expressions) {
const { startOffset, length } = expression.location
const expressionSource = source.substring(startOffset, startOffset + length)
const location = locationFromOffset(source, startOffset, length)

this.addOffense(
`Avoid unused expressions in silent ERB tags. \`${expressionSource}\` is evaluated but its return value is discarded. Use \`<%= ... %>\` to output the value or remove the expression.`,
location,
undefined,
undefined,
["unnecessary"],
)
}
}
}

export class ERBNoUnusedExpressionsRule extends ParserRule {
static ruleName = "erb-no-unused-expressions"
static introducedIn = this.version("unreleased")

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

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

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
const visitor = new ERBNoUnusedExpressionsVisitor(this.ruleName, context)

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 @@ -40,6 +40,7 @@ export * from "./erb-no-trailing-whitespace.js"
export * from "./erb-no-unsafe-js-attribute.js"
export * from "./erb-no-unsafe-raw.js"
export * from "./erb-no-unsafe-script-interpolation.js"
export * from "./erb-no-unused-expressions.js"
export * from "./erb-no-unused-literals.js"
export * from "./erb-prefer-image-tag-helper.js"
export * from "./erb-require-trailing-newline.js"
Expand Down
17 changes: 17 additions & 0 deletions javascript/packages/linter/src/rules/prism-rule-utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import type { PrismNode } from "@herb-tools/core"

export const DEBUG_OUTPUT_METHODS = new Set(["p", "pp", "puts", "print", "warn", "debug", "byebug"])
export const BINDING_DEBUGGER_METHODS = new Set(["pry", "irb"])

export function isAssignmentNode(prismNode: PrismNode): boolean {
const type: string = prismNode?.constructor?.name
if (!type) return false

return type.endsWith("WriteNode")
}

export function isDebugOutputCall(node: PrismNode): boolean {
if (!node.receiver && DEBUG_OUTPUT_METHODS.has(node.name)) return true

if (BINDING_DEBUGGER_METHODS.has(node.name)) {
const receiver = node.receiver

if (receiver?.constructor?.name === "CallNode") {
return receiver.name === "binding" && !receiver.receiver
}
}

return false
}
Loading
Loading