From 510233bf1f5808f7f31ce10a91de0aaf356525ef Mon Sep 17 00:00:00 2001 From: Marko Kajzer Date: Sat, 21 Mar 2026 16:22:14 +0100 Subject: [PATCH 1/2] Linter: Implement `erb-closing-tag-indent` rule --- .../packages/linter/docs/rules/README.md | 1 + .../docs/rules/erb-closing-tag-indent.md | 62 +++++++++ javascript/packages/linter/src/rules.ts | 6 +- .../src/rules/erb-closing-tag-indent.ts | 123 ++++++++++++++++++ javascript/packages/linter/src/rules/index.ts | 1 + .../test/__snapshots__/cli.test.ts.snap | 72 +++++----- .../erb-closing-tag-indent.autofix.test.ts | 85 ++++++++++++ .../test/rules/erb-closing-tag-indent.test.ts | 83 ++++++++++++ 8 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 javascript/packages/linter/docs/rules/erb-closing-tag-indent.md create mode 100644 javascript/packages/linter/src/rules/erb-closing-tag-indent.ts create mode 100644 javascript/packages/linter/test/autofix/erb-closing-tag-indent.autofix.test.ts create mode 100644 javascript/packages/linter/test/rules/erb-closing-tag-indent.test.ts diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index 7627b2886..0af2e647c 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -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 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 438ac81cb..a76d1477d 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -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" @@ -100,12 +101,13 @@ export const rules: RuleClass[] = [ ActionViewStrictLocalsFirstLineRule, ActionViewStrictLocalsPartialOnlyRule, + ERBClosingTagIndentRule, ERBCommentSyntax, ERBNoCaseNodeChildrenRule, - ERBNoEmptyControlFlowRule, ERBNoConditionalHTMLElementRule, ERBNoConditionalOpenTagRule, ERBNoDuplicateBranchElementsRule, + ERBNoEmptyControlFlowRule, ERBNoEmptyTagsRule, ERBNoExtraNewLineRule, ERBNoExtraWhitespaceRule, 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..5cfa43f8e --- /dev/null +++ b/javascript/packages/linter/src/rules/erb-closing-tag-indent.ts @@ -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 + fixType: "remove-newline" | "add-newline" | "fix-indent" + expectedIndent: number +} + +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 = 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 { + 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): 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 a42c37d30..a9cfbf42a 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -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" diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index bedc6b756..8b438ad1f 100644 --- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap +++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap @@ -109,7 +109,7 @@ test/fixtures/ignored.html.erb:8:8 Offenses 5 errors | 2 warnings (7 offenses across 1 file) Note 3 additional offenses reported (would have been ignored) Fixable 7 offenses | 4 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -170,7 +170,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version)" + Rules 73 enabled | 7 not enabled | 5 skipped (version)" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 1`] = ` @@ -193,11 +193,24 @@ test/fixtures/no-trailing-newline.html.erb:1:29 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 1 offense | 1 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version)" + Rules 73 enabled | 7 not enabled | 5 skipped (version)" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 2`] = ` -"[error] Remove extra whitespace after \`<%\`. (erb-no-extra-whitespace-inside-tags) [Correctable] +"[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 │ + + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [1/4] ⎯⎯⎯⎯ + +[error] Remove extra whitespace after \`<%\`. (erb-no-extra-whitespace-inside-tags) [Correctable] test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:2 @@ -207,7 +220,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:2 3 │ <% -⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [1/3] ⎯⎯⎯⎯ +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [2/4] ⎯⎯⎯⎯ [error] Remove extra whitespace before \`%>\`. (erb-no-extra-whitespace-inside-tags) [Correctable] @@ -219,7 +232,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:20 3 │ <% -⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [2/3] ⎯⎯⎯⎯ +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ [3/4] ⎯⎯⎯⎯ [error] Remove extra whitespace after \`<%=\`. (erb-no-extra-whitespace-inside-tags) [Correctable] @@ -236,13 +249,14 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:9:3 Rule offenses: erb-no-extra-whitespace-inside-tags (3 offenses in 1 file) + erb-closing-tag-indent (1 offense in 1 file) Summary: Checked 1 file - Offenses 3 errors | 0 warnings (3 offenses across 1 file) - Fixable 3 offenses | 3 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Offenses 4 errors | 0 warnings (4 offenses across 1 file) + Fixable 4 offenses | 4 autocorrectable using \`--fix\` + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -284,7 +298,7 @@ test/fixtures/ignored.html.erb:6:14 Checked 1 file Offenses 2 errors | 0 warnings | 3 ignored (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -311,7 +325,7 @@ test/fixtures/parser-errors.html.erb:2:16 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 1 offense - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -527,7 +541,7 @@ test/fixtures/multiple-rule-offenses.html.erb:4:2 Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -625,7 +639,7 @@ test/fixtures/few-rule-offenses.html.erb:6:0 Checked 1 file Offenses 4 errors | 2 warnings (6 offenses across 1 file) Fixable 6 offenses | 3 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -665,7 +679,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version)" + Rules 73 enabled | 7 not enabled | 5 skipped (version)" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for clean file 1`] = ` @@ -675,7 +689,7 @@ exports[`CLI Output Formatting > formats GitHub Actions output correctly for cle Summary: Checked 1 file Offenses 0 offenses - Rules 72 enabled | 7 not enabled | 5 skipped (version)" + Rules 73 enabled | 7 not enabled | 5 skipped (version)" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for file with errors 1`] = ` @@ -733,7 +747,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version)" + Rules 73 enabled | 7 not enabled | 5 skipped (version)" `; exports[`CLI Output Formatting > formats GitHub Actions output with --format=github option 1`] = ` @@ -770,7 +784,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -820,7 +834,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] "summary": { "filesChecked": 1, "filesWithOffenses": 1, - "ruleCount": 72, + "ruleCount": 73, "totalErrors": 2, "totalHints": 0, "totalIgnored": 0, @@ -841,7 +855,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for clean file 1` "summary": { "filesChecked": 1, "filesWithOffenses": 0, - "ruleCount": 72, + "ruleCount": 73, "totalErrors": 0, "totalHints": 0, "totalIgnored": 0, @@ -914,7 +928,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for file with err "summary": { "filesChecked": 1, "filesWithOffenses": 1, - "ruleCount": 72, + "ruleCount": 73, "totalErrors": 2, "totalHints": 0, "totalIgnored": 0, @@ -975,7 +989,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -995,7 +1009,7 @@ exports[`CLI Output Formatting > formats simple output correctly 1`] = ` Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -1015,7 +1029,7 @@ exports[`CLI Output Formatting > formats simple output for bad-file correctly 1` Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -1028,7 +1042,7 @@ exports[`CLI Output Formatting > formats success output correctly 1`] = ` Summary: Checked 1 file Offenses 0 offenses - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -1041,7 +1055,7 @@ exports[`CLI Output Formatting > handles boolean attributes 1`] = ` Summary: Checked 1 file Offenses 0 offenses - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -1077,7 +1091,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -1210,7 +1224,7 @@ test/fixtures/disabled-1.html.erb:14:19 Checked 1 file Offenses 2 errors | 6 warnings | 8 ignored (8 offenses across 1 file) Fixable 8 offenses | 1 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -1414,7 +1428,7 @@ test/fixtures/disabled-2.html.erb:2:44 Checked 1 file Offenses 6 errors | 7 warnings | 5 ignored (13 offenses across 1 file) Fixable 13 offenses | 6 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version) + Rules 73 enabled | 7 not enabled | 5 skipped (version) TIP: Run herb-lint --init to create a .herb.yml and lock the version. This ensures upgrading Herb won't enable new rules until you update the version in .herb.yml." @@ -1475,5 +1489,5 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 72 enabled | 7 not enabled | 5 skipped (version)" + Rules 73 enabled | 7 not enabled | 5 skipped (version)" `; 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..f0cb4f913 --- /dev/null +++ b/javascript/packages/linter/test/autofix/erb-closing-tag-indent.autofix.test.ts @@ -0,0 +1,85 @@ +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("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("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) + }) +}) 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..c6b7eb4ae --- /dev/null +++ b/javascript/packages/linter/test/rules/erb-closing-tag-indent.test.ts @@ -0,0 +1,83 @@ +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 +%>`) + }) + + 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 %> + `) + }) + }) + + 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 + %> + `) + }) + }) + + 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 %>") + }) + }) +}) From 8736afd1bf8b6b814e61e3787635e96d1f7ab434 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 1 Aug 2026 18:45:51 +0200 Subject: [PATCH 2/2] Format and add `introducedIn` --- .../packages/linter/src/rules/erb-closing-tag-indent.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/javascript/packages/linter/src/rules/erb-closing-tag-indent.ts b/javascript/packages/linter/src/rules/erb-closing-tag-indent.ts index 5cfa43f8e..a97b86436 100644 --- a/javascript/packages/linter/src/rules/erb-closing-tag-indent.ts +++ b/javascript/packages/linter/src/rules/erb-closing-tag-indent.ts @@ -1,7 +1,7 @@ -import type { ERBNode, ParseResult } from "@herb-tools/core" - import { BaseRuleVisitor } from "./rule-utils.js" import { ParserRule, BaseAutofixContext, Mutable } from "../types.js" + +import type { ERBNode, ParseResult } from "@herb-tools/core" import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js" interface ClosingErbTagIndentAutofixContext extends BaseAutofixContext { @@ -40,6 +40,7 @@ class ClosingErbTagIndentVisitor extends BaseRuleVisitor