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 @@ -8,6 +8,7 @@ This page contains documentation for all Herb Linter rules.
- [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
- [`erb-no-empty-tags`](./erb-no-empty-tags.md) - Disallow empty ERB tags
- [`erb-no-extra-newline`](./erb-no-extra-newline.md) - Disallow extra newlines.
- [`erb-no-extra-whitespace-inside-tags`](./erb-no-extra-whitespace-inside-tags.md) - Disallow multiple consecutive spaces inside ERB tags
- [`erb-no-output-control-flow`](./erb-no-output-control-flow.md) - Prevents outputting control flow blocks
- [`erb-no-silent-tag-in-attribute-name`](./erb-no-silent-tag-in-attribute-name.md) - Disallow ERB silent tags in HTML attribute names
- [`erb-prefer-image-tag-helper`](./erb-prefer-image-tag-helper.md) - Prefer `image_tag` helper over `<img>` with ERB expressions
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Linter Rule: Avoid extra whitespace inside ERB tags

**Rule:** `erb-no-extra-whitespace-inside-tags`

## Description

This rule disallows **multiple consecutive spaces** immediately inside ERB tags (`<%`, `<%=`) or before the closing delimiter (`%>`). It ensures that ERB code is consistently and cleanly formatted, with exactly one space after the opening tag and one space before the closing tag (when appropriate).

## Rationale

Excess whitespace inside ERB tags can lead to inconsistent formatting and untidy templates. By enforcing a consistent amount of whitespace inside ERB tags, this rule improves code readability, aligns with the formatter and style guide expectations, and avoids unnecessary visual noise.

## Examples

### ✅ Good

```erb
<%= output %>

<% if condition %>
True
<% end %>
```

### 🚫 Bad

```erb
<%= output %>

<%= output %>

<% if condition %>
True
<% end %>
```

## References

\-
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ERBCommentSyntax } from "./rules/erb-comment-syntax.js";
import { ERBNoCaseNodeChildrenRule } from "./rules/erb-no-case-node-children.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"
import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
import { ERBNoSilentTagInAttributeNameRule } from "./rules/erb-no-silent-tag-in-attribute-name.js"
import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
Expand Down Expand Up @@ -59,6 +60,7 @@ export const rules: RuleClass[] = [
ERBNoCaseNodeChildrenRule,
ERBNoEmptyTagsRule,
ERBNoExtraNewLineRule,
ERBNoExtraWhitespaceRule,
ERBNoOutputControlFlowRule,
ERBNoSilentTagInAttributeNameRule,
ERBPreferImageTagHelperRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { ParserRule, BaseAutofixContext, Mutable } from "../types.js"
import { BaseRuleVisitor } from "./rule-utils.js"

import type { ParseResult, Token, ERBNode } from "@herb-tools/core"
import { Location } from "@herb-tools/core"
import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"

interface ERBNoExtraWhitespaceAutofixContext extends BaseAutofixContext {
node: Mutable<ERBNode>
openTag: Token
closeTag: Token
content: string
fixType: "after-open" | "before-close" | "after-comment-equals"
}

class ERBNoExtraWhitespaceInsideTagsVisitor extends BaseRuleVisitor<ERBNoExtraWhitespaceAutofixContext> {

visitERBNode(node: ERBNode): void {
const openTag = node.tag_opening
const closeTag = node.tag_closing
const { value } = node.content ?? {}

if (!openTag || !closeTag || !value) return

if (this.hasExtraLeadingWhitespace(value)) {
this.reportWhitespace(node, openTag, closeTag, value, "start", 0, `Remove extra whitespace after \`${openTag.value}\`.`, "after-open")
}

if (openTag.value === "<%#" && value.startsWith("=") && value.length > 1) {
const afterEquals = value.substring(1)

if (afterEquals.match(/^\s{2,}/) && !afterEquals.startsWith(" \n") && !afterEquals.startsWith("\n")) {
this.reportWhitespace(node, openTag, closeTag, value, "start", 1, `Remove extra whitespace after \`<%#=\`.`, "after-comment-equals")
}
}

if (this.hasExtraTrailingWhitespace(value)) {
this.reportWhitespace(node, openTag, closeTag, value, "end", 0, `Remove extra whitespace before \`${closeTag.value}\`.`, "before-close")
}
}

private hasExtraLeadingWhitespace(content: string): boolean {
return content.startsWith(" ") && !content.startsWith(" \n")
}

private hasExtraTrailingWhitespace(content: string): boolean {
return !content.includes("\n") && /\s{2,}$/.test(content)
}

private getWhitespaceLocation(node: ERBNode, content: string, position: "start" | "end", offset: number = 0): Location {
const contentLocation = node.content!.location

if (position === "start") {
const match = content.substring(offset).match(/^\s+/)
const length = match ? match[0].length : 0
const startColumn = contentLocation.start.column + offset

return Location.from(
contentLocation.start.line,
startColumn,
contentLocation.start.line,
startColumn + length
)
} else {
const match = content.match(/\s+$/)
const length = match ? match[0].length : 0

return Location.from(
contentLocation.end.line,
contentLocation.end.column - length,
contentLocation.end.line,
contentLocation.end.column
)
}
}

private reportWhitespace(
node: ERBNode,
openTag: Token,
closeTag: Token,
content: string,
position: "start" | "end",
offset: number,
message: string,
fixType: "after-open" | "before-close" | "after-comment-equals"
): void {
const location = this.getWhitespaceLocation(node, content, position, offset)
this.addOffense(message, location, {
node,
openTag,
closeTag,
content,
fixType
})
}
}

export class ERBNoExtraWhitespaceRule extends ParserRule<ERBNoExtraWhitespaceAutofixContext> {
static autocorrectable = true
name = "erb-no-extra-whitespace-inside-tags"

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

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

visitor.visit(result.value)

return visitor.offenses
}

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

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

const content = node.content.value

switch (fixType) {
case "before-close":
node.content.value = content.replace(/\s{2,}$/, " ")
break

case "after-open":
node.content.value = content.replace(/^\s{2,}/, " ")
break

case "after-comment-equals":
if (content.startsWith("=")) {
const afterEquals = content.substring(1)
node.content.value = "= " + afterEquals.replace(/^\s{2,}/, "")
}

break
default:
return null
}

return result
}
}
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ export * from "./erb-comment-syntax.js"
export * from "./erb-no-case-node-children.js"
export * from "./erb-no-empty-tags.js"
export * from "./erb-no-extra-newline.js"
export * from "./erb-no-extra-whitespace-inside-tags.js"
export * from "./erb-no-output-control-flow.js"
export * from "./erb-no-silent-tag-in-attribute-name.js"
export * from "./erb-prefer-image-tag-helper.js"
export * from "./erb-require-trailing-newline.js"
export * from "./erb-require-whitespace-inside-tags.js"
export * from "./erb-right-trim.js"

export * from "./herb-disable-comment-valid-rule-name.js"
Expand Down
52 changes: 49 additions & 3 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