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 @@ -78,6 +78,7 @@ This page contains documentation for all Herb Linter rules.
- [`html-require-script-nonce`](./html-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
- [`source-indentation`](./source-indentation.md) - Indent with spaces instead of tabs.
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
- [`turbo-permanent-require-id`](./turbo-permanent-require-id.md) - Require `id` attribute on elements with `data-turbo-permanent`

Expand Down
33 changes: 33 additions & 0 deletions javascript/packages/linter/docs/rules/source-indentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Linter Rule: Indentation

**Rule:** `source-indentation`

## Description

Detects indentation with tabs. Consistent use of spaces for indentation improves readability and avoids alignment issues across editors and tools.

## Rationale

Mixing tabs and spaces for indentation causes inconsistent visual formatting across different editors, tools, and environments. Tabs render at different widths depending on the viewer's settings, which can make code appear misaligned or harder to read. Standardizing on space indentation ensures that code appears the same regardless of editor or tool, diffs and code reviews display consistently, and the codebase maintains a uniform visual style.

## Examples

### ✅ Good

```erb
<div>
<p>Hello</p>
</div>
```

### 🚫 Bad

```erb
<div>
<p>Hello</p>
</div>
```

## References

- [Shopify/erb_lint - `SpaceIndentation`](https://github.com/Shopify/erb_lint/blob/main/lib/erb_lint/linters/space_indentation.rb)
9 changes: 8 additions & 1 deletion javascript/packages/linter/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,8 @@ export class Linter {
context = {
...context,
validRuleNames: this.getAvailableRules().map(ruleClass => ruleClass.ruleName),
ignoredOffensesByLine
ignoredOffensesByLine,
indentWidth: context?.indentWidth ?? this.config?.formatter?.indentWidth
}

const regularRules = this.rules.filter(ruleClass => ruleClass.ruleName !== "herb-disable-comment-unnecessary")
Expand Down Expand Up @@ -522,6 +523,12 @@ export class Linter {
*/
autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[], options?: { includeUnsafe?: boolean }): AutofixResult {
const includeUnsafe = options?.includeUnsafe ?? false

context = {
...context,
indentWidth: context?.indentWidth ?? this.config?.formatter?.indentWidth
}

const lintResult = offensesToFix ? { offenses: offensesToFix } : this.lint(source, context)

const parserOffenses: LintOffense[] = []
Expand Down
4 changes: 4 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"

import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"

import { SourceIndentationRule } from "./rules/source-indentation.js"

import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"

import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js"
Expand Down Expand Up @@ -171,6 +173,8 @@ export const rules: RuleClass[] = [

ParserNoErrorsRule,

SourceIndentationRule,

SVGTagNameCapitalizationRule,

TurboPermanentRequireIdRule,
Expand Down
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 @@ -81,4 +81,6 @@ export * from "./html-require-closing-tags.js"
export * from "./html-require-script-nonce.js"
export * from "./html-tag-name-lowercase.js"

export * from "./source-indentation.js"

export * from "./svg-tag-name-capitalization.js"
69 changes: 69 additions & 0 deletions javascript/packages/linter/src/rules/source-indentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Location } from "@herb-tools/core"

import { BaseSourceRuleVisitor, positionFromOffset } from "./rule-utils.js"
import { SourceRule } from "../types.js"
import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"

const START_BLANKS = /^[^\S\n]*\t[^\S\n]*/

class SourceIndentationVisitor extends BaseSourceRuleVisitor {
protected visitSource(source: string): void {
const lines = source.split("\n")
let offset = 0

lines.forEach((line) => {
const match = line.match(START_BLANKS)

if (match) {
const start = positionFromOffset(source, offset)
const end = positionFromOffset(source, offset + match[0].length)
const location = new Location(start, end)

this.addOffense(
"Indent with spaces instead of tabs.",
location,
)
}

offset += line.length + 1
})
}
}

export class SourceIndentationRule extends SourceRule {
static autocorrectable = true
static ruleName = "source-indentation"
static introducedIn = this.version("unreleased")

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

check(source: string, context?: Partial<LintContext>): UnboundLintOffense[] {
const visitor = new SourceIndentationVisitor(this.ruleName, context)

visitor.visit(source)

return visitor.offenses
}

autofix(_offense: LintOffense, source: string, context?: Partial<LintContext>): string | null {
const indentWidth = context?.indentWidth ?? 2
const lines = source.split("\n")
const result = lines.map((line) => {
const match = line.match(START_BLANKS)

if (match) {
const replaced = match[0].replace(/\t/g, " ".repeat(indentWidth))
return replaced + line.substring(match[0].length)
}

return line
})

return result.join("\n")
}
}
4 changes: 3 additions & 1 deletion javascript/packages/linter/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export interface LintContext {
validRuleNames: string[] | undefined
ignoredOffensesByLine: Map<number, Set<string>> | undefined
ignoreDisableComments: boolean | undefined
indentWidth: number | undefined
}

/**
Expand All @@ -251,7 +252,8 @@ export const DEFAULT_LINT_CONTEXT: LintContext = {
fileName: undefined,
validRuleNames: undefined,
ignoredOffensesByLine: undefined,
ignoreDisableComments: undefined
ignoreDisableComments: undefined,
indentWidth: undefined
} as const

export abstract class SourceRule<TAutofixContext extends BaseAutofixContext = BaseAutofixContext> {
Expand Down
Loading
Loading