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 @@ -15,6 +15,7 @@ This page contains documentation for all Herb Linter rules.

#### ERB

- [`erb-closing-tag-indent`](./erb-closing-tag-indent.md) - Enforce consistent closing ERB tag indentation
- [`erb-comment-syntax`](./erb-comment-syntax.md) - Disallow Ruby comments immediately after ERB tags
- [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
- [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
Expand Down
62 changes: 62 additions & 0 deletions javascript/packages/linter/docs/rules/erb-closing-tag-indent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Linter Rule: Enforce consistent closing ERB tag indentation

**Rule:** `erb-closing-tag-indent`

## Description

This rule enforces that the closing ERB tag (`%>`) is consistently indented relative to its opening tag (`<%` or `<%=`). When an ERB tag spans multiple lines, the closing `%>` must be on its own line and indented to match the column position of the opening tag.

## Rationale

Inconsistent indentation of closing ERB tags makes templates harder to read and maintain. When an ERB tag spans multiple lines, the closing `%>` should visually align with the opening `<%` to clearly show the tag boundaries. Conversely, if the opening tag is on the same line as the content, the closing tag should also be on the same line.

## Examples

### ✅ Good

```erb
<%= title %>
```

```erb
<% if admin? %>
<h1>Content</h1>
<% end %>
```

```erb
<%
some_helper(
arg1,
arg2
)
%>
```

```erb
<%
if true
%>
```

### ❌ Bad

```erb
<% if true
%>
```

```erb
<%
if true %>
```

```erb
<%
if true
%>
```

## References

- [Inspiration: ERB Lint `ClosingErbTagIndent` rule](https://github.com/Shopify/erb_lint/blob/main/lib/erb_lint/linters/closing_erb_tag_indent.rb)
6 changes: 4 additions & 2 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import { ActionViewNoVoidElementContentRule } from "./rules/actionview-no-void-e
import { ActionViewStrictLocalsFirstLineRule } from "./rules/actionview-strict-locals-first-line.js"
import { ActionViewStrictLocalsPartialOnlyRule } from "./rules/actionview-strict-locals-partial-only.js"

import { ERBClosingTagIndentRule } from "./rules/erb-closing-tag-indent.js"
import { ERBCommentSyntax } from "./rules/erb-comment-syntax.js";
import { ERBNoCaseNodeChildrenRule } from "./rules/erb-no-case-node-children.js"
import { ERBNoEmptyControlFlowRule } from "./rules/erb-no-empty-control-flow.js"
import { ERBNoConditionalHTMLElementRule } from "./rules/erb-no-conditional-html-element.js"
import { ERBNoConditionalOpenTagRule } from "./rules/erb-no-conditional-open-tag.js"
import { ERBNoDuplicateBranchElementsRule } from "./rules/erb-no-duplicate-branch-elements.js"
import { ERBNoEmptyControlFlowRule } from "./rules/erb-no-empty-control-flow.js"
import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
import { ERBNoExtraNewLineRule } from "./rules/erb-no-extra-newline.js"
import { ERBNoExtraWhitespaceRule } from "./rules/erb-no-extra-whitespace-inside-tags.js"
Expand Down Expand Up @@ -100,12 +101,13 @@ export const rules: RuleClass[] = [
ActionViewStrictLocalsFirstLineRule,
ActionViewStrictLocalsPartialOnlyRule,

ERBClosingTagIndentRule,
ERBCommentSyntax,
ERBNoCaseNodeChildrenRule,
ERBNoEmptyControlFlowRule,
ERBNoConditionalHTMLElementRule,
ERBNoConditionalOpenTagRule,
ERBNoDuplicateBranchElementsRule,
ERBNoEmptyControlFlowRule,
ERBNoEmptyTagsRule,
ERBNoExtraNewLineRule,
ERBNoExtraWhitespaceRule,
Expand Down
123 changes: 123 additions & 0 deletions javascript/packages/linter/src/rules/erb-closing-tag-indent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { ERBNode, ParseResult } from "@herb-tools/core"

import { BaseRuleVisitor } from "./rule-utils.js"
import { ParserRule, BaseAutofixContext, Mutable } from "../types.js"
import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"

interface ClosingErbTagIndentAutofixContext extends BaseAutofixContext {
node: Mutable<ERBNode>
fixType: "remove-newline" | "add-newline" | "fix-indent"
expectedIndent: number
}

class ClosingErbTagIndentVisitor extends BaseRuleVisitor<ClosingErbTagIndentAutofixContext> {
visitERBNode(node: ERBNode): void {
const openTag = node.tag_opening
const closeTag = node.tag_closing
const content = node.content
if (!openTag || !closeTag || !content) return

const value = content.value
if (!value.length) return

const startsWithNewline = value.startsWith("\n")
const endsWithNewline = this.endsWithNewline(value)

if (!startsWithNewline && endsWithNewline) {
this.addOffense(
`Remove newline before \`${closeTag.value}\`. The opening \`${openTag.value}\` is not followed by a newline, so the closing tag should be on the same line.`,
closeTag.location,
{ node, fixType: "remove-newline", expectedIndent: 0 }
)
} else if (startsWithNewline && !endsWithNewline) {
const expectedIndent = openTag.location.start.column

this.addOffense(
`Add newline before \`${closeTag.value}\`. The opening \`${openTag.value}\` is followed by a newline, so the closing tag should be on its own line.`,
closeTag.location,
{ node, fixType: "add-newline", expectedIndent }
)
} else if (startsWithNewline && endsWithNewline) {
const expectedIndent = openTag.location.start.column
const actualIndent = this.trailingIndent(value)
if (actualIndent === expectedIndent) return

this.addOffense(
`Incorrect indentation for \`${closeTag.value}\`. Expected ${expectedIndent} ${expectedIndent === 1 ? "space" : "spaces"} but found ${actualIndent}.`,
closeTag.location,
{ node, fixType: "fix-indent", expectedIndent }
)
}
}

private endsWithNewline(value: string): boolean {
const lastNewlineIndex = value.lastIndexOf("\n")
if (lastNewlineIndex === -1) return false

const afterLastNewline = value.substring(lastNewlineIndex + 1)
return afterLastNewline.length === 0 || /^\s*$/.test(afterLastNewline)
}

private trailingIndent(value: string): number {
const lastNewlineIndex = value.lastIndexOf("\n")
if (lastNewlineIndex === -1) return 0

return value.length - lastNewlineIndex - 1
}
}

export class ERBClosingTagIndentRule extends ParserRule<ClosingErbTagIndentAutofixContext> {
static autocorrectable = true
static reindentAfterAutofix = true
static ruleName = "erb-closing-tag-indent"

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

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

visitor.visit(result.value)

return visitor.offenses
}

autofix(offense: LintOffense<ClosingErbTagIndentAutofixContext>, result: ParseResult, _context?: Partial<LintContext>): ParseResult | null {
if (!offense.autofixContext) return null

const { node, fixType, expectedIndent } = offense.autofixContext
if (!node.content) return null

const content = node.content.value

switch (fixType) {
case "add-newline": {
const trimmed = content.trimEnd()
node.content.value = trimmed + "\n" + " ".repeat(expectedIndent)

return result
}
case "remove-newline": {
const lastNewlineIndex = content.lastIndexOf("\n")
if (lastNewlineIndex === -1) return null

const beforeNewline = content.substring(0, lastNewlineIndex).trimEnd()
node.content.value = beforeNewline + " "

return result
}
case "fix-indent": {
const lastNewlineIndex = content.lastIndexOf("\n")
if (lastNewlineIndex === -1) return null

node.content.value = content.substring(0, lastNewlineIndex + 1) + " ".repeat(expectedIndent)

return result
}
}
}
}
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 @@ -11,6 +11,7 @@ export * from "./actionview-no-void-element-content.js"
export * from "./actionview-strict-locals-first-line.js"
export * from "./actionview-strict-locals-partial-only.js"

export * from "./erb-closing-tag-indent.js"
export * from "./erb-comment-syntax.js"
export * from "./erb-no-case-node-children.js"
export * from "./erb-no-conditional-open-tag.js"
Expand Down
Loading
Loading