Skip to content
Draft
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 @@ -38,6 +38,7 @@ This page contains documentation for all Herb Linter rules.
- [`herb-disable-comment-no-redundant-all`](./herb-disable-comment-no-redundant-all.md) - Disallow redundant use of `all` in `herb:disable` comments.
- [`herb-disable-comment-unnecessary`](./herb-disable-comment-unnecessary.md) - Detect unnecessary `herb:disable` comments.
- [`herb-disable-comment-valid-rule-name`](./herb-disable-comment-valid-rule-name.md) - Validate rule names in `herb:disable` comments.
- [`herb-formatter-well-formatted`](./herb-formatter-well-formatted.md) - Check source is well-formatted.
- [`html-allowed-script-type`](./html-allowed-script-type.md) - Restrict allowed `type` attributes for `<script>` tags
- [`html-anchor-require-href`](./html-anchor-require-href.md) - Requires an href attribute on anchor tags
- [`html-aria-attribute-must-be-valid`](./html-aria-attribute-must-be-valid.md) - Disallow invalid or unknown `aria-*` attributes.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Linter Rule: Check source is well-formatted

**Rule:** `herb-formatter-well-formatted`

## Description

This rule checks if the source code matches the output of the Herb Formatter. It shows formatting issues as diagnostics (squiggly lines in editors), allowing you to see what would change when formatting is applied.

## Configuration

This rule is **disabled by default**. To enable it, you must:

1. Enable the formatter in your `.herb.yml`
2. Enable this rule

```yaml
formatter:
enabled: true

linter:
rules:
herb-formatter-well-formatted:
enabled: true
```

The rule will only run when both conditions are met. It also respects the formatter's file exclusion patterns.

## Rationale

This rule bridges the gap between linting and formatting by surfacing formatting issues as lint warnings. This is useful when:

* You want to see formatting issues inline in your editor before running the formatter
* You want CI to fail on unformatted code without actually modifying files
* You prefer reviewing formatting changes before applying them

To fix the issues, run the Herb formatter CLI: `herb format`.

## Messages

The rule generates contextual messages based on the type of formatting issue:

* **Incorrect indentation: expected N spaces, found M** - When indentation differs
* **Unexpected whitespace** - When extra whitespace should be removed
* **Missing whitespace** - When whitespace should be added
* **Incorrect line breaks** - When line break count differs
* **Formatting differs from expected** - Generic message for other differences

## Examples

### ✅ Good

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

```erb
<%= render partial: "header" %>
```

### 🚫 Bad

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

```erb
<div> </div>
```

```erb
<div>
<p>Too much indentation</p>
</div>
```

## References

- [GitHub Issue #916](https://github.com/marcoroth/herb/issues/916)
1 change: 1 addition & 0 deletions javascript/packages/linter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"dependencies": {
"@herb-tools/config": "0.8.10",
"@herb-tools/core": "0.8.10",
"@herb-tools/formatter": "0.8.10",
"@herb-tools/highlighter": "0.8.10",
"@herb-tools/node-wasm": "0.8.10",
"@herb-tools/printer": "0.8.10",
Expand Down
1 change: 1 addition & 0 deletions javascript/packages/linter/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@herb-tools/config:build",
"@herb-tools/core:build",
"@herb-tools/browser:build",
"@herb-tools/formatter:build",
"@herb-tools/highlighter:build",
"@herb-tools/printer:build",
"@herb-tools/rewriter:build"
Expand Down
3 changes: 2 additions & 1 deletion javascript/packages/linter/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,8 @@ export class Linter {
context = {
...context,
validRuleNames: this.getAvailableRules().map(ruleClass => ruleClass.ruleName),
ignoredOffensesByLine
ignoredOffensesByLine,
config: this.config
}

const regularRules = this.rules.filter(ruleClass => ruleClass.ruleName !== "herb-disable-comment-unnecessary")
Expand Down
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { HerbDisableCommentNoDuplicateRulesRule } from "./rules/herb-disable-com
import { HerbDisableCommentNoRedundantAllRule } from "./rules/herb-disable-comment-no-redundant-all.js"
import { HerbDisableCommentUnnecessaryRule } from "./rules/herb-disable-comment-unnecessary.js"
import { HerbDisableCommentValidRuleNameRule } from "./rules/herb-disable-comment-valid-rule-name.js"
import { HerbFormatterWellFormattedRule } from "./rules/herb-formatter-well-formatted.js"

import { HTMLAllowedScriptTypeRule } from "./rules/html-allowed-script-type.js"
import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
Expand Down Expand Up @@ -120,6 +121,7 @@ export const rules: RuleClass[] = [
HerbDisableCommentNoRedundantAllRule,
HerbDisableCommentUnnecessaryRule,
HerbDisableCommentValidRuleNameRule,
HerbFormatterWellFormattedRule,

HTMLAllowedScriptTypeRule,
HTMLAnchorRequireHrefRule,
Expand Down
245 changes: 245 additions & 0 deletions javascript/packages/linter/src/rules/herb-formatter-well-formatted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { Herb } from "@herb-tools/node-wasm"
import { Location } from "@herb-tools/core"
import { Formatter } from "@herb-tools/formatter"

import { SourceRule } from "../types.js"
import { positionFromOffset } from "./rule-utils.js"

import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"

interface DiffRange {
startOffset: number
endOffset: number
originalContent: string
formattedContent: string
}

export class HerbFormatterWellFormattedRule extends SourceRule {
static autocorrectable = false
static ruleName = "herb-formatter-well-formatted"

get defaultConfig(): FullRuleConfig {
return {
enabled: false,
severity: "warning"
}
}

isEnabled(_source: string, context?: Partial<LintContext>): boolean {
const config = context?.config

if (!config) return true
if (!config.isFormatterEnabled) return false
if (context?.fileName && !config.isFormatterEnabledForPath(context.fileName)) return false

return true
}

check(source: string, context?: Partial<LintContext>): UnboundLintOffense[] {
const offenses: UnboundLintOffense[] = []

if (source.length === 0) return offenses

try {
const config = context?.config
const filePath = context?.fileName
const formatter = config ? Formatter.from(Herb, config) : new Formatter(Herb)
const formatted = formatter.format(source, {}, filePath)

if (source === formatted) return offenses

const diffs = this.computeDiffs(source, formatted)

for (const diff of diffs) {
const startPosition = positionFromOffset(source, diff.startOffset)
const endPosition = positionFromOffset(source, diff.endOffset)
const location = Location.from(startPosition.line, startPosition.column, endPosition.line, endPosition.column)
const message = this.generateMessage(diff)

offenses.push({
rule: this.ruleName,
code: this.ruleName,
source: "Herb Linter",
message,
location
})
}
} catch {
// Skip silently on formatting errors
}

return offenses
}

private computeDiffs(original: string, formatted: string): DiffRange[] {
const diffs: DiffRange[] = []
const originalLength = original.length
const formattedLength = formatted.length

let originalIndex = 0
let formattedIndex = 0

while (originalIndex < originalLength || formattedIndex < formattedLength) {
if (originalIndex < originalLength && formattedIndex < formattedLength && original[originalIndex] === formatted[formattedIndex]) {
originalIndex++
formattedIndex++
continue
}

const diffStartOriginal = originalIndex
const diffStartFormatted = formattedIndex
const syncPoint = this.findSyncPoint(original, formatted, originalIndex, formattedIndex)

if (syncPoint) {
const originalEnd = syncPoint.originalIndex
const formattedEnd = syncPoint.formattedIndex

if (originalEnd > diffStartOriginal || formattedEnd > diffStartFormatted) {
diffs.push({
startOffset: diffStartOriginal,
endOffset: Math.max(diffStartOriginal + 1, originalEnd),
originalContent: original.slice(diffStartOriginal, originalEnd),
formattedContent: formatted.slice(diffStartFormatted, formattedEnd)
})
}

originalIndex = originalEnd
formattedIndex = formattedEnd
} else {
diffs.push({
startOffset: diffStartOriginal,
endOffset: originalLength,
originalContent: original.slice(diffStartOriginal),
formattedContent: formatted.slice(diffStartFormatted)
})

break
}
}

return this.coalesceDiffs(diffs)
}

private findSyncPoint(original: string, formatted: string, originalStart: number, formattedStart: number): { originalIndex: number; formattedIndex: number } | null {
const originalLength = original.length
const formattedLength = formatted.length
const maxLookahead = 100
const minMatchLength = 3

for (let originalOffset = 1; originalOffset <= maxLookahead && originalStart + originalOffset <= originalLength; originalOffset++) {
for (let formattedOffset = 1; formattedOffset <= maxLookahead && formattedStart + formattedOffset <= formattedLength; formattedOffset++) {
const originalPosition = originalStart + originalOffset
const formattedPosition = formattedStart + formattedOffset

let matchLength = 0

while (
originalPosition + matchLength < originalLength &&
formattedPosition + matchLength < formattedLength &&
original[originalPosition + matchLength] === formatted[formattedPosition + matchLength]
) {
matchLength++
if (matchLength >= minMatchLength) {
return { originalIndex: originalPosition, formattedIndex: formattedPosition }
}
}

if (originalPosition === originalLength && formattedPosition === formattedLength) {
return { originalIndex: originalPosition, formattedIndex: formattedPosition }
}
}
}

if (originalStart >= originalLength && formattedStart < formattedLength) {
return { originalIndex: originalLength, formattedIndex: formattedLength }
}

if (formattedStart >= formattedLength && originalStart < originalLength) {
return { originalIndex: originalLength, formattedIndex: formattedLength }
}

return null
}

private coalesceDiffs(diffs: DiffRange[]): DiffRange[] {
if (diffs.length <= 1) return diffs

const coalesced: DiffRange[] = []
let current = diffs[0]

for (let i = 1; i < diffs.length; i++) {
const next = diffs[i]

if (next.startOffset <= current.endOffset + 1) {
current = {
startOffset: current.startOffset,
endOffset: Math.max(current.endOffset, next.endOffset),
originalContent: current.originalContent + next.originalContent,
formattedContent: current.formattedContent + next.formattedContent
}
} else {
coalesced.push(current)
current = next
}
}

coalesced.push(current)

return coalesced
}

private generateMessage(diff: DiffRange): string {
const { originalContent, formattedContent } = diff

const originalIndentMatch = originalContent.match(/^[ \t]+/)
const formattedIndentMatch = formattedContent.match(/^[ \t]+/)

if (originalIndentMatch || formattedIndentMatch) {
const originalIndent = originalIndentMatch?.[0] || ""
const formattedIndent = formattedIndentMatch?.[0] || ""

if (originalIndent !== formattedIndent) {
const originalSpaces = this.countIndentation(originalIndent)
const formattedSpaces = this.countIndentation(formattedIndent)

if (originalSpaces !== formattedSpaces) {
return `Incorrect indentation: expected ${formattedSpaces} spaces, found ${originalSpaces}`
}
}
}

const originalIsWhitespace = /^[\s]+$/.test(originalContent)
const formattedIsWhitespace = /^[\s]+$/.test(formattedContent)

if (originalIsWhitespace && formattedContent === "") {
return "Unexpected whitespace"
}

if (originalContent === "" && formattedIsWhitespace) {
return "Missing whitespace"
}

const originalNewlines = (originalContent.match(/\n/g) || []).length
const formattedNewlines = (formattedContent.match(/\n/g) || []).length

if (originalNewlines !== formattedNewlines) {
return "Incorrect line breaks"
}

return "Formatting differs from expected"
}

private countIndentation(indent: string): number {
let count = 0

for (const char of indent) {
if (char === "\t") {
count += 2
} else {
count++
}
}

return count
}
}
Loading
Loading