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
32 changes: 32 additions & 0 deletions javascript/packages/linter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,38 @@ JSON output fields:
- `clean`: Whether there were no offenses (`null` when `completed=false`)
- `message`: Error or informational message (`null` on success)

### Disabling Rules Inline <Badge type="info" text="v0.8.0+" />

You can disable linting rules for specific lines using inline comments. This is useful when you need to allow certain code that would otherwise trigger a linting offense.

#### Disabling a Single Rule

Add a comment at the end of the line with `herb:disable` followed by the rule name:

```erb
<DIV>test</DIV> <%# herb:disable html-tag-name-lowercase %>
```

#### Disabling Multiple Rules

You can disable multiple rules on the same line by separating rule names with commas:

```erb
<DIV id='test' class="<%= "hello" %>">test</DIV> <%# herb:disable html-tag-name-lowercase, html-attribute-double-quotes %>
```

#### Disabling All Rules

To disable all linting rules for a specific line, use `all`:

```erb
<DIV id='test' class="<%= "hello" %>">test</DIV> <%# herb:disable all %>
```

::: warning Important
Inline disable comments only affect the line they appear on. Each line that needs linting disabled must have its own disable comment.
:::

### Language Server Integration

The linter is automatically integrated into the [Herb Language Server](https://herb-tools.dev/projects/language-server), providing real-time validation in supported editors like VS Code, Zed, and Neovim.
43 changes: 23 additions & 20 deletions javascript/packages/linter/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,36 +49,39 @@ export class Linter {
}

private filterOffenses(ruleOffenses: LintOffense[], sourceLines: string[], ruleName: string): { kept: LintOffense[], ignored: LintOffense[] } {
const kept: LintOffense[] = [];
const ignored: LintOffense[] = [];
const kept: LintOffense[] = []
const ignored: LintOffense[] = []

for (const offense of ruleOffenses) {
const line = offense.location.start.line;
const line = offense.location.start.line
if (line > sourceLines.length) {
kept.push(offense);
continue;
kept.push(offense)
continue
}
const lineContent = sourceLines[line - 1];
const lineContent = sourceLines[line - 1]

const disableCommentRegex = /<%#\s+herb:disable\s+(.*)%>/;
const match = lineContent.match(disableCommentRegex);
const disabledRules = this.parseHerbDisable(lineContent)

if (match) {
const rulesRaw = (match && match[1]) || '';
const rules = rulesRaw.split(",").map((rule) => rule.trim());
if (rules.includes(ruleName) || rules.includes("all")) {
ignored.push(offense);
} else {
kept.push(offense);
}
} else {
kept.push(offense);
if (disabledRules.includes(ruleName) || disabledRules.includes("all")) {
ignored.push(offense)
continue
}

kept.push(offense)
}

return { kept, ignored };
return { kept, ignored }
}

private parseHerbDisable(sourceLine: string) {
// Matches <%# herb:disable rule1, rule2, ... %> anywhere in the string
const regex = /<%#\s*herb:disable\s*([a-zA-Z0-9_-]+(?:\s*,\s*[a-zA-Z0-9_-]+)*)\s*%>/
const match = sourceLine.match(regex)
if (!match) return []
return match[1].split(/\s*,\s*/)
}


/**
* Lint source code using Parser/AST, Lexer, and Source rules.
* @param source - The source code to lint
Expand Down Expand Up @@ -213,7 +216,7 @@ export class Linter {

if (offense.autofixContext) {
const originalNodeType = offense.autofixContext.node.type
const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location
const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location

const freshNode = findNodeByLocation(
parseResult.value,
Expand Down
3 changes: 1 addition & 2 deletions javascript/packages/linter/src/rules/rule-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
Visitor,
Position,
Location,
getStaticAttributeName,
hasDynamicAttributeName as hasNodeDynamicAttributeName,
Expand Down Expand Up @@ -89,7 +88,7 @@ export abstract class ControlFlowTrackingVisitor<TAutofixContext extends BaseAut
/**
* Handle visiting a control flow node with proper scope management
*/
protected handleControlFlowNode(node: Node, controlFlowType: ControlFlowType, visitChildren: () => void): void {
protected handleControlFlowNode(_node: Node, controlFlowType: ControlFlowType, visitChildren: () => void): void {
const wasInControlFlow = this.isInControlFlow
const previousControlFlowType = this.currentControlFlowType

Expand Down
70 changes: 69 additions & 1 deletion javascript/packages/linter/test/linter.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import dedent from "dedent"
import { describe, test, expect, beforeAll } from "vitest"

import { Herb } from "@herb-tools/node-wasm"
import { Location } from "@herb-tools/core"
import { Linter } from "../src/linter.js"

import { HTMLTagNameLowercaseRule } from "../src/rules/html-tag-name-lowercase.js"
import { HTMLAttributeDoubleQuotesRule } from "../src/rules/html-attribute-double-quotes.js"
import { HTMLAttributeValuesRequireQuotesRule } from "../src/rules/html-attribute-values-require-quotes.js"
import { ParserRule, SourceRule } from "../src/types.js"

import type { LintOffense, LintContext } from "../src/types.js"
Expand Down Expand Up @@ -196,12 +199,77 @@ describe("@herb-tools/linter", () => {
})

test("can disable a rule with a comment", () => {
const html = '<DIV>test</DIV> <%# herb:disable html-tag-name-lowercase %>'
const html = dedent`
<DIV>test</DIV> <%# herb:disable html-tag-name-lowercase %>
`

const linter = new Linter(Herb, [HTMLTagNameLowercaseRule])
const lintResult = linter.lint(html)

expect(lintResult.offenses).toHaveLength(0)
expect(lintResult.ignored).toBe(2)
})

test("can disable multiple rules with a comment", () => {
const html = dedent`
<DIV id='1' class=<%= "hello" %>>test</DIV><%# herb:disable html-tag-name-lowercase, html-attribute-double-quotes %>
`

const linter = new Linter(
Herb,
[
HTMLTagNameLowercaseRule,
HTMLAttributeDoubleQuotesRule,
HTMLAttributeValuesRequireQuotesRule,
],
)

const lintResult = linter.lint(html)

expect(lintResult.offenses).toHaveLength(1)
expect(lintResult.ignored).toBe(3)
})

test("can disable multiple rules with a comment and whitespace between comma and rules", () => {
const html = dedent`
<DIV id='1' class=<%= "hello" %>>test</DIV><%# herb:disable html-tag-name-lowercase, html-attribute-double-quotes %>
<DIV id='1' class=<%= "hello" %>>test</DIV><%# herb:disable html-tag-name-lowercase ,html-attribute-double-quotes %>
<DIV id='1' class=<%= "hello" %>>test</DIV><%# herb:disable html-tag-name-lowercase , html-attribute-double-quotes %>
`

const linter = new Linter(
Herb,
[
HTMLTagNameLowercaseRule,
HTMLAttributeDoubleQuotesRule,
HTMLAttributeValuesRequireQuotesRule,
],
)

const lintResult = linter.lint(html)

expect(lintResult.offenses).toHaveLength(3)
expect(lintResult.ignored).toBe(9)
})

test("can disable all rules with a comment", () => {
const html = dedent`
<DIV id='1' class=<%= "hello" %>>test</DIV> <%# herb:disable all %>
`

const linter = new Linter(
Herb,
[
HTMLTagNameLowercaseRule,
HTMLAttributeDoubleQuotesRule,
HTMLAttributeValuesRequireQuotesRule
],
)

const lintResult = linter.lint(html)

expect(lintResult.offenses).toHaveLength(0)
expect(lintResult.ignored).toBe(4)
})
})
})
Loading