diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md
index 0c9fcc21a..6a5932d4b 100644
--- a/javascript/packages/linter/docs/rules/README.md
+++ b/javascript/packages/linter/docs/rules/README.md
@@ -43,6 +43,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-byte-order-mark`](./erb-no-byte-order-mark.md) - Disallow a byte order mark at the start of a template
- [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
diff --git a/javascript/packages/linter/docs/rules/erb-closing-tag-indent.md b/javascript/packages/linter/docs/rules/erb-closing-tag-indent.md
new file mode 100644
index 000000000..53be52447
--- /dev/null
+++ b/javascript/packages/linter/docs/rules/erb-closing-tag-indent.md
@@ -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? %>
+
Content
+<% 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)
diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts
index c17159fb8..843cbe3e3 100644
--- a/javascript/packages/linter/src/rules.ts
+++ b/javascript/packages/linter/src/rules.ts
@@ -32,6 +32,7 @@ import { ActionViewPreferQualifiedPartialPathRule } from "./rules/actionview-pre
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 { ERBNoByteOrderMarkRule } from "./rules/erb-no-byte-order-mark.js"
import { ERBNoCaseNodeChildrenRule } from "./rules/erb-no-case-node-children.js"
@@ -183,6 +184,7 @@ export const rules: RuleClass[] = [
ActionViewStrictLocalsFirstLineRule,
ActionViewStrictLocalsPartialOnlyRule,
+ ERBClosingTagIndentRule,
ERBCommentSyntax,
ERBNoByteOrderMarkRule,
ERBNoCaseNodeChildrenRule,
diff --git a/javascript/packages/linter/src/rules/erb-closing-tag-indent.ts b/javascript/packages/linter/src/rules/erb-closing-tag-indent.ts
new file mode 100644
index 000000000..14273cfcd
--- /dev/null
+++ b/javascript/packages/linter/src/rules/erb-closing-tag-indent.ts
@@ -0,0 +1,188 @@
+import { BaseRuleVisitor } from "./rule-utils.js"
+import { ParserRule, BaseAutofixContext, Mutable } from "../types.js"
+import { PrismVisitor, PrismNodes, substringFromByteOffset } from "@herb-tools/core"
+
+import type { ERBNode, ParseResult, ParserOptions } from "@herb-tools/core"
+import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"
+
+interface ClosingErbTagIndentAutofixContext extends BaseAutofixContext {
+ node: Mutable
+ fixType: "remove-newline" | "add-newline" | "fix-indent"
+ expectedIndent: number
+}
+
+type StringLikeNode = PrismNodes.StringNode | PrismNodes.InterpolatedStringNode | PrismNodes.XStringNode | PrismNodes.InterpolatedXStringNode
+
+class HeredocDetector extends PrismVisitor {
+ public found = false
+
+ constructor(private readonly source: string) {
+ super()
+ }
+
+ visitStringNode(node: PrismNodes.StringNode): void {
+ this.visitStringLikeNode(node)
+ }
+
+ visitInterpolatedStringNode(node: PrismNodes.InterpolatedStringNode): void {
+ this.visitStringLikeNode(node)
+ }
+
+ visitXStringNode(node: PrismNodes.XStringNode): void {
+ this.visitStringLikeNode(node)
+ }
+
+ visitInterpolatedXStringNode(node: PrismNodes.InterpolatedXStringNode): void {
+ this.visitStringLikeNode(node)
+ }
+
+ private visitStringLikeNode(node: StringLikeNode): void {
+ const opening = node.openingLoc
+
+ if (opening && substringFromByteOffset(this.source, opening.startOffset, opening.length).startsWith("<<")) {
+ this.found = true
+ return
+ }
+
+ this.visitChildNodes(node)
+ }
+}
+
+class ClosingErbTagIndentVisitor extends BaseRuleVisitor {
+ 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 = this.startsWithNewline(value) || this.containsHeredoc(node)
+ 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 startsWithNewline(value: string): boolean {
+ return /^\s*\r?\n/.test(value)
+ }
+
+ private containsHeredoc(node: ERBNode): boolean {
+ if (!("prismNode" in node)) return false
+
+ const prismNode = node.prismNode
+ const source = node.source
+ if (!prismNode || !source) return false
+
+ const detector = new HeredocDetector(source)
+ detector.visit(prismNode)
+
+ return detector.found
+ }
+
+ 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 {
+ static autocorrectable = true
+ static ruleName = "erb-closing-tag-indent"
+ static introducedIn = this.version("unreleased")
+
+ get defaultConfig(): FullRuleConfig {
+ return {
+ enabled: true,
+ severity: "error"
+ }
+ }
+
+ get parserOptions(): Partial {
+ return {
+ prism_nodes: true
+ }
+ }
+
+ check(result: ParseResult, context?: Partial): UnboundLintOffense[] {
+ const visitor = new ClosingErbTagIndentVisitor(this.ruleName, context)
+
+ visitor.visit(result.value)
+
+ return visitor.offenses
+ }
+
+ autofix(offense: LintOffense, result: ParseResult, _context?: Partial): 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
+ }
+ }
+ }
+}
diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts
index f2ef5fba8..363b7ad4d 100644
--- a/javascript/packages/linter/src/rules/index.ts
+++ b/javascript/packages/linter/src/rules/index.ts
@@ -38,6 +38,7 @@ export * from "./actionview-prefer-qualified-partial-path.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-byte-order-mark.js"
export * from "./erb-no-case-node-children.js"
diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap
index 7bf3ef5ac..034e3481a 100644
--- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap
+++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap
@@ -19,7 +19,7 @@ test-file-with-errors.html.erb:
Failing 2 errors (2 offenses across 1 file)
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 140 enabled | all rules via --all-rules"
+ Rules 141 enabled | all rules via --all-rules"
`;
exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > \`--only\` replaces the counts from a disabled \`all\` 1`] = `
@@ -50,7 +50,7 @@ exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` su
Checked 1 file
Offenses 0 offenses
Fixable 0 offenses
- Rules 0 enabled | 140 not enabled
+ Rules 0 enabled | 141 not enabled
No rules enabled:
Every linter rule is turned off, so no offenses can be reported.
@@ -71,7 +71,7 @@ exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` su
Checked 1 file
Offenses 0 offenses
Fixable 0 offenses
- Rules 0 enabled | 140 not enabled
+ Rules 0 enabled | 141 not enabled
No rules enabled:
Every linter rule is turned off, so no offenses can be reported.
@@ -102,7 +102,7 @@ test-file-with-errors.html.erb:
Failing 2 errors (2 offenses across 1 file)
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 140 enabled"
+ Rules 141 enabled"
`;
exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > \`all: enabled: true\` reports no version-skipped rules 1`] = `
@@ -124,7 +124,7 @@ test-file-with-errors.html.erb:
Failing 2 errors (2 offenses across 1 file)
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 140 enabled"
+ Rules 141 enabled"
`;
exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > reports enabled and not-enabled rules when no \`all\` is configured 1`] = `
@@ -146,7 +146,7 @@ test/fixtures/test-file-with-errors.html.erb:
Failing 2 errors (2 offenses across 1 file)
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > rules opted back in on top of \`all: enabled: false\` count as enabled 1`] = `
@@ -165,7 +165,7 @@ test-file-with-errors.html.erb:
Failing 0 offenses
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 0 offenses
- Rules 1 enabled | 139 not enabled"
+ Rules 1 enabled | 140 not enabled"
`;
exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > rules opted out on top of \`all: enabled: true\` count as disabled 1`] = `
@@ -184,7 +184,7 @@ test-file-with-errors.html.erb:
Checked 1 file
Offenses 2 errors (2 offenses across 1 file)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 139 enabled | 1 disabled"
+ Rules 140 enabled | 1 disabled"
`;
exports[`CLI Output Formatting > --all-rules > reports every offense for the fixture with --all-rules 1`] = `
@@ -214,7 +214,7 @@ test/fixtures/all-rules.html.erb:
Failing 0 offenses
Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error)
Fixable 0 offenses
- Rules 140 enabled | all rules via --all-rules"
+ Rules 141 enabled | all rules via --all-rules"
`;
exports[`CLI Output Formatting > --all-rules > reports nothing for the fixture with the default rule set 1`] = `
@@ -226,7 +226,7 @@ exports[`CLI Output Formatting > --all-rules > reports nothing for the fixture w
Checked 1 file
Offenses 0 offenses
Fixable 0 offenses
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > --ignore-disable-comments 1`] = `
@@ -377,7 +377,7 @@ test/fixtures/ignored.html.erb:8:8
Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error)
Note 3 additional offenses reported (would have been ignored)
Fixable 7 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > --log-level > counts the hidden offenses and suggests the level that reveals them 1`] = `
@@ -395,7 +395,7 @@ exports[`CLI Output Formatting > --log-level > counts the hidden offenses and su
Not failing 2 info | 1 hint (3 offenses across 1 file, below --fail-level=error)
Not shown 3 offenses hidden, show them with --log-level=hint
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > --log-level > doesn't report offenses below the given level 1`] = `
@@ -412,7 +412,7 @@ exports[`CLI Output Formatting > --log-level > doesn't report offenses below the
Not failing 1 hint (1 offense across 1 file, below --fail-level=error)
Not shown 1 offense hidden, show it with --log-level=hint
Fixable 0 offenses
- Rules 118 enabled | 21 not enabled | 1 disabled"
+ Rules 119 enabled | 21 not enabled | 1 disabled"
`;
exports[`CLI Output Formatting > --log-level > prefers the CLI flag over the config file 1`] = `
@@ -431,7 +431,7 @@ test-file-with-errors.html.erb:
Failing 0 offenses
Not failing 1 hint (1 offense across 1 file, below --fail-level=error)
Fixable 0 offenses
- Rules 118 enabled | 21 not enabled | 1 disabled"
+ Rules 119 enabled | 21 not enabled | 1 disabled"
`;
exports[`CLI Output Formatting > --log-level > reads logLevel from the config file 1`] = `
@@ -448,7 +448,7 @@ exports[`CLI Output Formatting > --log-level > reads logLevel from the config fi
Not failing 1 hint (1 offense across 1 file, below --fail-level=error)
Not shown 1 offense hidden, show it with --log-level=hint
Fixable 0 offenses
- Rules 118 enabled | 21 not enabled | 1 disabled"
+ Rules 119 enabled | 21 not enabled | 1 disabled"
`;
exports[`CLI Output Formatting > --log-level > still counts hidden offenses towards the exit code 1`] = `
@@ -464,7 +464,7 @@ exports[`CLI Output Formatting > --log-level > still counts hidden offenses towa
Offenses 1 hint (1 offense across 1 file)
Not shown 1 offense hidden, show it with --log-level=hint
Fixable 0 offenses
- Rules 118 enabled | 21 not enabled | 1 disabled"
+ Rules 119 enabled | 21 not enabled | 1 disabled"
`;
exports[`CLI Output Formatting > --log-level > still reports offenses at or above the given level 1`] = `
@@ -483,7 +483,7 @@ test-file-with-errors.html.erb:
Failing 0 offenses
Not failing 1 hint (1 offense across 1 file, below --fail-level=error)
Fixable 0 offenses
- Rules 118 enabled | 21 not enabled | 1 disabled"
+ Rules 119 enabled | 21 not enabled | 1 disabled"
`;
exports[`CLI Output Formatting > --log-level > with --all-rules > keeps the log level when it is passed explicitly 1`] = `
@@ -505,7 +505,7 @@ test-file-with-errors.html.erb:
Not failing 1 hint (1 offense across 1 file, below --fail-level=error)
Not shown 1 offense hidden, show it with --log-level=hint
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 140 enabled | all rules via --all-rules"
+ Rules 141 enabled | all rules via --all-rules"
`;
exports[`CLI Output Formatting > --log-level > with --all-rules > lowers the log level to report the rules it was asked for 1`] = `
@@ -528,7 +528,7 @@ test-file-with-errors.html.erb:
Not failing 1 hint (1 offense across 1 file, below --fail-level=error)
Log level hint | lowered from warning by --all-rules
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 140 enabled | all rules via --all-rules"
+ Rules 141 enabled | all rules via --all-rules"
`;
exports[`CLI Output Formatting > --log-level > with --only > keeps the log level when it is passed explicitly 1`] = `
@@ -689,7 +689,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22
Failing 2 errors (2 offenses across 1 file)
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > GitHub Actions format includes rule codes 1`] = `
@@ -721,7 +721,7 @@ test/fixtures/no-trailing-newline.html.erb:1:29
Checked 1 file
Offenses 1 error (1 offense across 1 file)
Fixable 1 offense | 1 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > GitHub Actions format includes rule codes 2`] = `
@@ -739,7 +739,30 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:9:21
11 │ %>
-⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [1/5] ⎯⎯⎯⎯
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [1/6] ⎯⎯⎯⎯
+
+[error] Remove newline before \`%>\`. The opening \`<%=\` is not followed by a newline, so the closing tag should be on the same line. (erb-closing-tag-indent) [Correctable]
+
+test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:11:0
+
+ 9 │ <%= render partial: "post",
+ 10 │ as: :post
+ → 11 │ %>
+ │ ~~
+ 12 │
+
+ Running --fix would correct this to:
+
+ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb
+
+ 9 │ <%= render partial: "post",
+ - 10 │ as: :post
+ - 11 │ %>
+ + │ as: :post %>
+ 12 │
+
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [2/6] ⎯⎯⎯⎯
[error] Remove extra whitespace after \`<%\`. (erb-no-extra-whitespace-inside-tags) [Correctable]
@@ -759,7 +782,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:2
2 │
-⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [2/5] ⎯⎯⎯⎯
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [3/6] ⎯⎯⎯⎯
[error] Remove extra whitespace before \`%>\`. (erb-no-extra-whitespace-inside-tags) [Correctable]
@@ -779,7 +802,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:20
2 │
-⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [3/5] ⎯⎯⎯⎯
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [4/6] ⎯⎯⎯⎯
[error] Remove extra whitespace after \`<%=\`. (erb-no-extra-whitespace-inside-tags) [Correctable]
@@ -802,7 +825,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:9:3
10 │ as: :post
-⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [4/5] ⎯⎯⎯⎯
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [5/6] ⎯⎯⎯⎯
[error] Avoid unused expressions in silent ERB tags. \`<% extra_whitespace %>\` is evaluated but its return value is discarded. Use \`<%= extra_whitespace %>\` to output the value or remove the expression. (erb-no-unused-expressions)
@@ -818,15 +841,16 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:4
Rule offenses:
erb-no-extra-whitespace-inside-tags (3 offenses in 1 file)
actionview-prefer-qualified-partial-path (1 offense in 1 file)
+ erb-closing-tag-indent (1 offense in 1 file)
erb-no-unused-expressions (1 offense in 1 file)
Summary:
Checked 1 file
- Failing 4 errors (4 offenses across 1 file)
+ Failing 5 errors (5 offenses across 1 file)
Not failing 1 info (1 offense across 1 file, below --fail-level=error)
- Fixable 5 offenses | 3 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Fixable 6 offenses | 4 autocorrectable using \`--fix\`
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > Ignores disabled rules 1`] = `
@@ -886,7 +910,7 @@ test/fixtures/ignored.html.erb:6:14
Offenses 2 errors (2 offenses across 1 file)
Ignored 3 offenses suppressed with herb:disable
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > New rules available hint > keeps the space before the version label inside the gray sequence 1`] = `" ]8;;https://herb-tools.dev/linter/rules/svg-tag-name-capitalization\\[37msvg-tag-name-capitalization[0m]8;;\\[90m (introduced in 0.4.2)[0m"`;
@@ -948,7 +972,7 @@ test/fixtures/tag-attributes.html.erb:5:0
Failing 0 offenses
Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > diplays only parsers errors if one is present 1`] = `
@@ -974,7 +998,7 @@ test/fixtures/parser-errors.html.erb:2:16
Checked 1 file
Offenses 1 error (1 offense across 1 file)
Fixable 0 offenses
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > displays most violated rules with multiple offenses 1`] = `
@@ -1226,7 +1250,7 @@ test/fixtures/multiple-rule-offenses.html.erb:4:7
Failing 8 errors (8 offenses across 1 file)
Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > displays rule offenses when showing all rules 1`] = `
@@ -1351,7 +1375,7 @@ test/fixtures/few-rule-offenses.html.erb:6:0
Failing 4 errors (4 offenses across 1 file)
Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error)
Fixable 6 offenses | 3 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > formats GitHub Actions output correctly for bad file 1`] = `
@@ -1406,7 +1430,7 @@ test/fixtures/bad-file.html.erb:1:16
Checked 1 file
Offenses 2 errors (2 offenses across 1 file)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > formats GitHub Actions output correctly for clean file 1`] = `
@@ -1418,7 +1442,7 @@ exports[`CLI Output Formatting > formats GitHub Actions output correctly for cle
Checked 1 file
Offenses 0 offenses
Fixable 0 offenses
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > formats GitHub Actions output correctly for file with errors 1`] = `
@@ -1497,7 +1521,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22
Failing 2 errors (2 offenses across 1 file)
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > formats GitHub Actions output with --format=github option 1`] = `
@@ -1554,7 +1578,7 @@ test/fixtures/test-file-simple.html.erb:2:22
Checked 1 file
Offenses 2 errors (2 offenses across 1 file)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] = `
@@ -1601,7 +1625,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`]
"summary": {
"filesChecked": 1,
"filesWithOffenses": 1,
- "ruleCount": 119,
+ "ruleCount": 120,
"totalErrors": 2,
"totalHints": 0,
"totalIgnored": 0,
@@ -1623,7 +1647,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for clean file 1`
"summary": {
"filesChecked": 1,
"filesWithOffenses": 0,
- "ruleCount": 119,
+ "ruleCount": 120,
"totalErrors": 0,
"totalHints": 0,
"totalIgnored": 0,
@@ -1697,7 +1721,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for file with err
"summary": {
"filesChecked": 1,
"filesWithOffenses": 1,
- "ruleCount": 119,
+ "ruleCount": 120,
"totalErrors": 2,
"totalHints": 0,
"totalIgnored": 0,
@@ -1780,7 +1804,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22
Failing 2 errors (2 offenses across 1 file)
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > formats simple output correctly 1`] = `
@@ -1799,7 +1823,7 @@ test/fixtures/test-file-simple.html.erb:
Checked 1 file
Offenses 2 errors (2 offenses across 1 file)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > formats simple output for bad-file correctly 1`] = `
@@ -1818,7 +1842,7 @@ test/fixtures/bad-file.html.erb:
Checked 1 file
Offenses 2 errors (2 offenses across 1 file)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > formats success output correctly 1`] = `
@@ -1830,7 +1854,7 @@ exports[`CLI Output Formatting > formats success output correctly 1`] = `
Checked 1 file
Offenses 0 offenses
Fixable 0 offenses
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > handles boolean attributes 1`] = `
@@ -1842,7 +1866,7 @@ exports[`CLI Output Formatting > handles boolean attributes 1`] = `
Checked 1 file
Offenses 0 offenses
Fixable 0 offenses
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > handles multiple errors correctly 1`] = `
@@ -1893,7 +1917,7 @@ test/fixtures/bad-file.html.erb:1:16
Checked 1 file
Offenses 2 errors (2 offenses across 1 file)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > herb:disable rules 1`] = `
@@ -2036,7 +2060,7 @@ test/fixtures/disabled-1.html.erb:14:19
Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error)
Ignored 8 offenses suppressed with herb:disable
Fixable 8 offenses | 1 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > herb:disable rules 2`] = `
@@ -2295,7 +2319,7 @@ test/fixtures/disabled-2.html.erb:2:44
Not failing 7 warnings (7 offenses across 1 file, below --fail-level=error)
Ignored 5 offenses suppressed with herb:disable
Fixable 13 offenses | 6 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > missing \`framework\` option > reports the missing option per template 1`] = `
@@ -2314,7 +2338,7 @@ clean-file.html.erb:
Failing 0 offenses
Not failing 1 info (1 offense across 1 file, below --fail-level=error)
Fixable 0 offenses
- Rules 1 enabled | 139 not enabled"
+ Rules 1 enabled | 140 not enabled"
`;
exports[`CLI Output Formatting > non-failing offenses tip > points at --log-level when many offenses don't fail the build 1`] = `
@@ -2352,7 +2376,7 @@ multiple-rule-offenses.html.erb:
Failing 3 errors (3 offenses across 1 file)
Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled
+ Rules 120 enabled | 21 not enabled
TIP: 11 of the logged offenses don't fail the build.
Run herb-lint --log-level=error to stop logging them, or set linter.logLevel in your .herb.yml.
@@ -2384,7 +2408,7 @@ multiple-rule-offenses.html.erb:
Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error)
Not shown 11 offenses hidden, show them with --log-level=hint
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when --log-level is passed explicitly, even when it hides nothing 1`] = `
@@ -2422,7 +2446,7 @@ multiple-rule-offenses.html.erb:
Failing 3 errors (3 offenses across 1 file)
Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when logLevel is set in the config file 1`] = `
@@ -2460,7 +2484,7 @@ multiple-rule-offenses.html.erb:
Failing 3 errors (3 offenses across 1 file)
Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when only a handful of offenses don't fail the build 1`] = `
@@ -2498,7 +2522,7 @@ multiple-rule-offenses.html.erb:
Failing 8 errors (8 offenses across 1 file)
Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > points at the flag instead of previewing once there are too many corrections 1`] = `
@@ -2594,7 +2618,7 @@ test/fixtures/test-file-simple.html.erb:2:22
Checked 1 file
Offenses 2 errors (2 offenses across 1 file)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > previews the correction under each correctable offense 1`] = `
@@ -2651,7 +2675,7 @@ test/fixtures/test-file-simple.html.erb:2:22
Checked 1 file
Offenses 2 errors (2 offenses across 1 file)
Fixable 2 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is hint 1`] = `
@@ -2688,7 +2712,7 @@ test/fixtures/multiple-rule-offenses.html.erb:
Checked 1 file
Offenses 8 errors | 6 warnings (14 offenses across 1 file)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is info 1`] = `
@@ -2725,7 +2749,7 @@ test/fixtures/multiple-rule-offenses.html.erb:
Checked 1 file
Offenses 8 errors | 6 warnings (14 offenses across 1 file)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is warning 1`] = `
@@ -2762,7 +2786,7 @@ test/fixtures/multiple-rule-offenses.html.erb:
Checked 1 file
Offenses 8 errors | 6 warnings (14 offenses across 1 file)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > summary offense buckets > moves a severity between buckets as --fail-level is lowered 1`] = `
@@ -2800,7 +2824,7 @@ multiple-rule-offenses.html.erb:
Failing 8 errors | 4 warnings | 1 info (13 offenses across 1 file)
Not failing 1 hint (1 offense across 1 file, below --fail-level=info)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > summary offense buckets > splits the buckets when only some severities fail the build 1`] = `
@@ -2838,7 +2862,7 @@ multiple-rule-offenses.html.erb:
Failing 8 errors | 4 warnings (12 offenses across 1 file)
Not failing 1 info | 1 hint (2 offenses across 1 file, below --fail-level=warning)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
exports[`CLI Output Formatting > unsafe autocorrectable offenses > counts and tags them separately from offenses --fix can correct 1`] = `
@@ -2958,5 +2982,5 @@ test/fixtures/test-file-with-errors.html.erb:2:22
Failing 2 errors (2 offenses across 1 file)
Not failing 1 warning (1 offense across 1 file, below --fail-level=error)
Fixable 3 offenses | 2 autocorrectable using \`--fix\`
- Rules 119 enabled | 21 not enabled"
+ Rules 120 enabled | 21 not enabled"
`;
diff --git a/javascript/packages/linter/test/autofix/erb-closing-tag-indent.autofix.test.ts b/javascript/packages/linter/test/autofix/erb-closing-tag-indent.autofix.test.ts
new file mode 100644
index 000000000..d6269a89b
--- /dev/null
+++ b/javascript/packages/linter/test/autofix/erb-closing-tag-indent.autofix.test.ts
@@ -0,0 +1,174 @@
+import { describe, test, expect, beforeAll } from "vitest"
+
+import { Herb } from "@herb-tools/node-wasm"
+
+import { Linter } from "../../src/linter.js"
+import { ERBClosingTagIndentRule } from "../../src/rules/erb-closing-tag-indent.js"
+import dedent from "dedent"
+
+describe("erb-closing-tag-indent autofix", () => {
+ beforeAll(async () => {
+ await Herb.load()
+ })
+
+ test("removes newline before closing tag when opening is not followed by newline", () => {
+ const input = '<%= title\n%>'
+ const expected = '<%= title %>'
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(expected)
+ expect(result.fixed).toHaveLength(1)
+ })
+
+ test("removes newline and indentation before closing tag", () => {
+ const input = '<%= title\n %>'
+ const expected = '<%= title %>'
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(expected)
+ expect(result.fixed).toHaveLength(1)
+ })
+
+ test("moves a block ERB comment closing tag onto the content line", () => {
+ const input = dedent`
+ <%# Non-link tag that stands for skipped pages...
+ - available local variables
+ current_page: a page object for the currently displayed page
+ total_pages: total number of pages
+ per_page: number of items to fetch per page
+ remote: data-remote
+ -%>
+ `
+ const expected = dedent`
+ <%# Non-link tag that stands for skipped pages...
+ - available local variables
+ current_page: a page object for the currently displayed page
+ total_pages: total number of pages
+ per_page: number of items to fetch per page
+ remote: data-remote -%>
+ `
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(expected)
+ expect(result.fixed).toHaveLength(1)
+ expect(linter.lint(result.source).offenses).toHaveLength(0)
+ })
+
+ test("adds newline before closing tag when opening is followed by newline", () => {
+ const input = '<%=\n title %>'
+ const expected = '<%=\n title\n%>'
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(expected)
+ expect(result.fixed).toHaveLength(1)
+ })
+
+ test("preserves horizontal whitespace before the opening newline", () => {
+ const input = "<%= \n title %>"
+ const expected = "<%= \n title\n%>"
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(expected)
+ expect(result.fixed).toHaveLength(1)
+ })
+
+ test("adds indentation to closing tag to match opening tag", () => {
+ const input = '<%=\n title\n %>'
+ const expected = '<%=\n title\n%>'
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(expected)
+ expect(result.fixed).toHaveLength(1)
+ })
+
+ test("preserves already correct single-line tags", () => {
+ const input = dedent`
+ <% if admin? %>
+ Hello
+ <% end %>
+ `
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(input)
+ expect(result.fixed).toHaveLength(0)
+ })
+
+ test("preserves already correct multi-line tags", () => {
+ const input = dedent`
+ <%=
+ title
+ %>
+ `
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(input)
+ expect(result.fixed).toHaveLength(0)
+ })
+
+ test("preserves the newline after a heredoc terminator", () => {
+ const input = dedent`
+ <%= render(<<~TEXT)
+ hello
+ TEXT
+ %>
+ `
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(input)
+ expect(result.fixed).toHaveLength(0)
+ })
+
+ test("fixes closing tag indentation without changing a heredoc terminator", () => {
+ const input = " <%= render(<<~TEXT)\n hello\n TEXT\n %>"
+ const expected = " <%= render(<<~TEXT)\n hello\n TEXT\n %>"
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(expected)
+ expect(result.fixed).toHaveLength(1)
+ expect(Herb.parse(result.source).successful).toBe(true)
+ })
+
+ test("does not reindent surrounding content while fixing a nested ERB tag", () => {
+ const input = dedent`
+
+ <%
+ value = true
+ %>
+
+ `
+ const expected = dedent`
+
+ <%
+ value = true
+ %>
+
+ `
+
+ const linter = new Linter(Herb, [ERBClosingTagIndentRule])
+ const result = linter.autofix(input)
+
+ expect(result.source).toBe(expected)
+ expect(result.fixed).toHaveLength(1)
+ expect(linter.lint(result.source).offenses).toHaveLength(0)
+ })
+})
diff --git a/javascript/packages/linter/test/rules/erb-closing-tag-indent.test.ts b/javascript/packages/linter/test/rules/erb-closing-tag-indent.test.ts
new file mode 100644
index 000000000..ff5fd61b8
--- /dev/null
+++ b/javascript/packages/linter/test/rules/erb-closing-tag-indent.test.ts
@@ -0,0 +1,127 @@
+import dedent from "dedent"
+import { describe, test } from "vitest"
+import { ERBClosingTagIndentRule } from "../../src/rules/erb-closing-tag-indent.js"
+import { createLinterTest } from "../helpers/linter-test-helper.js"
+
+const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(ERBClosingTagIndentRule)
+
+describe("ERBClosingTagIndentRule", () => {
+ test("ignores on empty ERB tag", () => {
+ expectNoOffenses(dedent`<% %>`)
+ })
+
+ test("ignores single-line ERB output tag", () => {
+ expectNoOffenses(dedent`<%= title %>`)
+ })
+
+ test("passes on single-line ERB with matching end statement", () => {
+ expectNoOffenses(dedent`
+ <% if admin? %>
+ Content
+ <% end %>
+ `)
+ })
+
+ test("passes on multi-line ERB with matching indent", () => {
+ expectNoOffenses(dedent`
+ <%=
+ some_helper(
+ arg1,
+ arg2
+ )
+ %>
+ `)
+ })
+
+ test("passes on multi-line ERB at beginning of line", () => {
+ expectNoOffenses(dedent`<%=
+title
+%>`)
+ })
+
+ test("passes when horizontal whitespace precedes the opening newline", () => {
+ expectNoOffenses("<%= \t\n title\n%>")
+ })
+
+ test("passes on heredoc with matching indent", () => {
+ expectNoOffenses(dedent`
+ <%= render(<<~TEXT)
+ hello
+ TEXT
+ %>
+ `)
+ })
+
+ test("reports a misindented closing tag after a heredoc", () => {
+ expectError("Incorrect indentation for `%>`. Expected 0 spaces but found 2.")
+
+ assertOffenses(dedent`
+ <%= render(<<~TEXT)
+ hello
+ TEXT
+ %>
+ `)
+ })
+
+ describe("missing newline before closing tag", () => {
+ test("handles closing tag not followed by matching newline", () => {
+ expectError("Add newline before `%>`. The opening `<%=` is followed by a newline, so the closing tag should be on its own line.")
+
+ assertOffenses(dedent`
+ <%=
+ title %>
+ `)
+ })
+
+ test("handles horizontal whitespace before the opening newline", () => {
+ expectError("Add newline before `%>`. The opening `<%=` is followed by a newline, so the closing tag should be on its own line.")
+
+ assertOffenses("<%= \n title %>")
+ })
+ })
+
+ describe("superfluous newline before closing tag", () => {
+ test("handles closing tag followed by additional newline", () => {
+ expectError("Remove newline before `%>`. The opening `<%=` is not followed by a newline, so the closing tag should be on the same line.")
+
+ assertOffenses(dedent`
+ <%= title
+ %>
+ `)
+ })
+
+ test("handles block ERB comments", () => {
+ expectError("Remove newline before `-%>`. The opening `<%#` is not followed by a newline, so the closing tag should be on the same line.")
+
+ assertOffenses(dedent`
+ <%# Non-link tag that stands for skipped pages...
+ - available local variables
+ current_page: a page object for the currently displayed page
+ total_pages: total number of pages
+ per_page: number of items to fetch per page
+ remote: data-remote
+ -%>
+ `)
+ })
+ })
+
+ describe("incorrect indentation", () => {
+ test("handles closing tag indented more than opening tag", () => {
+ expectError("Incorrect indentation for `%>`. Expected 0 spaces but found 2.")
+
+ assertOffenses("<%=\n title\n %>")
+ })
+
+ test("handles closing tag indented less than opening tag", () => {
+ expectError("Incorrect indentation for `%>`. Expected 2 spaces but found 0.")
+
+ assertOffenses(" <%=\n title\n%>")
+ })
+
+ test("handles mismatched indent on closing tag", () => {
+ expectError("Incorrect indentation for `%>`. Expected 4 spaces but found 2.")
+
+ assertOffenses(" <%=\n title\n %>")
+ })
+ })
+})