Skip to content

Commit cf9ac07

Browse files
authored
Linter: Implement source-indentation rule (#1444)
1 parent ee6b125 commit cf9ac07

File tree

10 files changed

+340
-26
lines changed

10 files changed

+340
-26
lines changed

javascript/packages/linter/docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ This page contains documentation for all Herb Linter rules.
7878
- [`html-require-script-nonce`](./html-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
7979
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
8080
- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
81+
- [`source-indentation`](./source-indentation.md) - Indent with spaces instead of tabs.
8182
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
8283
- [`turbo-permanent-require-id`](./turbo-permanent-require-id.md) - Require `id` attribute on elements with `data-turbo-permanent`
8384

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Linter Rule: Indentation
2+
3+
**Rule:** `source-indentation`
4+
5+
## Description
6+
7+
Detects indentation with tabs. Consistent use of spaces for indentation improves readability and avoids alignment issues across editors and tools.
8+
9+
## Rationale
10+
11+
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.
12+
13+
## Examples
14+
15+
### ✅ Good
16+
17+
```erb
18+
<div>
19+
<p>Hello</p>
20+
</div>
21+
```
22+
23+
### 🚫 Bad
24+
25+
```erb
26+
<div>
27+
<p>Hello</p>
28+
</div>
29+
```
30+
31+
## References
32+
33+
- [Shopify/erb_lint - `SpaceIndentation`](https://github.com/Shopify/erb_lint/blob/main/lib/erb_lint/linters/space_indentation.rb)

javascript/packages/linter/src/linter.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,8 @@ export class Linter {
409409
context = {
410410
...context,
411411
validRuleNames: this.getAvailableRules().map(ruleClass => ruleClass.ruleName),
412-
ignoredOffensesByLine
412+
ignoredOffensesByLine,
413+
indentWidth: context?.indentWidth ?? this.config?.formatter?.indentWidth
413414
}
414415

415416
const regularRules = this.rules.filter(ruleClass => ruleClass.ruleName !== "herb-disable-comment-unnecessary")
@@ -522,6 +523,12 @@ export class Linter {
522523
*/
523524
autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[], options?: { includeUnsafe?: boolean }): AutofixResult {
524525
const includeUnsafe = options?.includeUnsafe ?? false
526+
527+
context = {
528+
...context,
529+
indentWidth: context?.indentWidth ?? this.config?.formatter?.indentWidth
530+
}
531+
525532
const lintResult = offensesToFix ? { offenses: offensesToFix } : this.lint(source, context)
526533

527534
const parserOffenses: LintOffense[] = []

javascript/packages/linter/src/rules.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
8383

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

86+
import { SourceIndentationRule } from "./rules/source-indentation.js"
87+
8688
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
8789

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

172174
ParserNoErrorsRule,
173175

176+
SourceIndentationRule,
177+
174178
SVGTagNameCapitalizationRule,
175179

176180
TurboPermanentRequireIdRule,

javascript/packages/linter/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,6 @@ export * from "./html-require-closing-tags.js"
8181
export * from "./html-require-script-nonce.js"
8282
export * from "./html-tag-name-lowercase.js"
8383

84+
export * from "./source-indentation.js"
85+
8486
export * from "./svg-tag-name-capitalization.js"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Location } from "@herb-tools/core"
2+
3+
import { BaseSourceRuleVisitor, positionFromOffset } from "./rule-utils.js"
4+
import { SourceRule } from "../types.js"
5+
import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"
6+
7+
const START_BLANKS = /^[^\S\n]*\t[^\S\n]*/
8+
9+
class SourceIndentationVisitor extends BaseSourceRuleVisitor {
10+
protected visitSource(source: string): void {
11+
const lines = source.split("\n")
12+
let offset = 0
13+
14+
lines.forEach((line) => {
15+
const match = line.match(START_BLANKS)
16+
17+
if (match) {
18+
const start = positionFromOffset(source, offset)
19+
const end = positionFromOffset(source, offset + match[0].length)
20+
const location = new Location(start, end)
21+
22+
this.addOffense(
23+
"Indent with spaces instead of tabs.",
24+
location,
25+
)
26+
}
27+
28+
offset += line.length + 1
29+
})
30+
}
31+
}
32+
33+
export class SourceIndentationRule extends SourceRule {
34+
static autocorrectable = true
35+
static ruleName = "source-indentation"
36+
static introducedIn = this.version("unreleased")
37+
38+
get defaultConfig(): FullRuleConfig {
39+
return {
40+
enabled: true,
41+
severity: "error"
42+
}
43+
}
44+
45+
check(source: string, context?: Partial<LintContext>): UnboundLintOffense[] {
46+
const visitor = new SourceIndentationVisitor(this.ruleName, context)
47+
48+
visitor.visit(source)
49+
50+
return visitor.offenses
51+
}
52+
53+
autofix(_offense: LintOffense, source: string, context?: Partial<LintContext>): string | null {
54+
const indentWidth = context?.indentWidth ?? 2
55+
const lines = source.split("\n")
56+
const result = lines.map((line) => {
57+
const match = line.match(START_BLANKS)
58+
59+
if (match) {
60+
const replaced = match[0].replace(/\t/g, " ".repeat(indentWidth))
61+
return replaced + line.substring(match[0].length)
62+
}
63+
64+
return line
65+
})
66+
67+
return result.join("\n")
68+
}
69+
}

javascript/packages/linter/src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ export interface LintContext {
242242
validRuleNames: string[] | undefined
243243
ignoredOffensesByLine: Map<number, Set<string>> | undefined
244244
ignoreDisableComments: boolean | undefined
245+
indentWidth: number | undefined
245246
}
246247

247248
/**
@@ -251,7 +252,8 @@ export const DEFAULT_LINT_CONTEXT: LintContext = {
251252
fileName: undefined,
252253
validRuleNames: undefined,
253254
ignoredOffensesByLine: undefined,
254-
ignoreDisableComments: undefined
255+
ignoreDisableComments: undefined,
256+
indentWidth: undefined
255257
} as const
256258

257259
export abstract class SourceRule<TAutofixContext extends BaseAutofixContext = BaseAutofixContext> {

0 commit comments

Comments
 (0)