diff --git a/javascript/packages/formatter/README.md b/javascript/packages/formatter/README.md index 8bcda4789..25ba384ee 100644 --- a/javascript/packages/formatter/README.md +++ b/javascript/packages/formatter/README.md @@ -244,6 +244,34 @@ herb-format --force app/views/excluded-file.html.erb When using `--force` on an excluded file, the formatter will show a warning but proceed with formatting. +### Disabling Formatting for Entire Files + +You can disable formatting for an entire file by adding the `ignore` directive anywhere in your template: + +```erb +<%# herb:formatter ignore %> +``` + +**Example:** + +:::code-group +```erb [ignored.html.erb] +<%# herb:formatter ignore %> + +
This entire file will not be formatted
+``` + +```erb [formatted.html.erb] +
+
This file will be formatted
+
+``` +::: + +::: warning Important +The `<%# herb:formatter ignore %>` directive must be an exact match. Extra text or spacing will prevent it from working. +::: + ## Rewriters The formatter supports **rewriters** that allow you to transform templates before and after formatting. diff --git a/javascript/packages/formatter/src/format-ignore.ts b/javascript/packages/formatter/src/format-ignore.ts new file mode 100644 index 000000000..9f117d9ed --- /dev/null +++ b/javascript/packages/formatter/src/format-ignore.ts @@ -0,0 +1,51 @@ +import { isERBCommentNode } from "@herb-tools/core" +import { Visitor } from "@herb-tools/core" + +import type { Node, ERBContentNode } from "@herb-tools/core" + +const HERB_FORMATTER_PREFIX = "herb:formatter" +const HERB_FORMATTER_IGNORE_PREFIX = `${HERB_FORMATTER_PREFIX} ignore` + +/** + * Check if an ERB content node is a herb:formatter ignore comment + */ +export function isHerbFormatterIgnoreComment(node: Node): boolean { + if (!isERBCommentNode(node)) return false + + const content = node?.content?.value || "" + + return content.trim() === HERB_FORMATTER_IGNORE_PREFIX +} + +/** + * Check if the document contains a herb:formatter ignore directive anywhere. + */ +export function hasFormatterIgnoreDirective(node: Node): boolean { + const detector = new FormatterIgnoreDetector() + detector.visit(node) + return detector.hasIgnoreDirective +} + +/** +* Visitor that detects if the AST contains a herb:formatter ignore directive. +*/ +class FormatterIgnoreDetector extends Visitor { + public hasIgnoreDirective = false + + visitNode(node: Node): void { + if (this.hasIgnoreDirective) return + + this.visitChildNodes(node) + } + + visitERBContentNode(node: ERBContentNode): void { + if (isHerbFormatterIgnoreComment(node)) { + this.hasIgnoreDirective = true + return + } + + if (this.hasIgnoreDirective) return + + this.visitChildNodes(node) + } +} diff --git a/javascript/packages/formatter/src/formatter.ts b/javascript/packages/formatter/src/formatter.ts index 7230765f4..261c32491 100644 --- a/javascript/packages/formatter/src/formatter.ts +++ b/javascript/packages/formatter/src/formatter.ts @@ -2,13 +2,13 @@ import { FormatPrinter } from "./format-printer.js" import { isScaffoldTemplate } from "./scaffold-template-detector.js" import { resolveFormatOptions } from "./options.js" +import { hasFormatterIgnoreDirective } from "./format-ignore.js" import type { Config } from "@herb-tools/config" import type { RewriteContext } from "@herb-tools/rewriter" import type { HerbBackend, ParseResult } from "@herb-tools/core" import type { FormatOptions } from "./options.js" - /** * Formatter uses a Herb Backend to parse the source and then * formats the resulting AST into a well-indented, wrapped string. @@ -61,6 +61,7 @@ export class Formatter { if (result.failed) return source if (isScaffoldTemplate(result)) return source + if (hasFormatterIgnoreDirective(result.value)) return source const resolvedOptions = resolveFormatOptions({ ...this.options, ...options }) diff --git a/javascript/packages/formatter/test/herb-formatter-ignore.test.ts b/javascript/packages/formatter/test/herb-formatter-ignore.test.ts new file mode 100644 index 000000000..8224eb47b --- /dev/null +++ b/javascript/packages/formatter/test/herb-formatter-ignore.test.ts @@ -0,0 +1,120 @@ +import { describe, test, expect, beforeAll } from "vitest" +import { Herb } from "@herb-tools/node-wasm" +import { Formatter } from "../src" +import dedent from "dedent" + +let formatter: Formatter + +describe("herb:formatter ignore directive", () => { + beforeAll(async () => { + await Herb.load() + formatter = new Formatter(Herb, { + indentWidth: 2, + maxLineLength: 80, + }) + }) + + test("should ignore formatting when directive is at top of file", () => { + const source = dedent` + <%# herb:formatter ignore %> +
+ Badly formatted content +
+ ` + + const result = formatter.format(source) + expect(result).toBe(source) + }) + + test("should ignore formatting when directive is in middle of file", () => { + const source = dedent` +
+ <%# herb:formatter ignore %> + Badly formatted content +
+ ` + + const result = formatter.format(source) + expect(result).toBe(source) + }) + + test("should work with frontmatter before directive", () => { + const source = dedent` + --- + title: Test + --- + <%# herb:formatter ignore %> +
+ content +
+ ` + + const result = formatter.format(source) + expect(result).toBe(source) + }) + + test("should work with whitespace before directive", () => { + const source = ` + + <%# herb:formatter ignore %> +
+ content +
+ ` + + const result = formatter.format(source) + expect(result).toBe(source) + }) + + test("should not match herb:formatter ignore with extra text", () => { + const source = dedent` + <%# herb:formatter ignore some-rule %> +
+ content +
+ ` + + const result = formatter.format(source) + expect(result).not.toBe(source) + }) + + test("should not match herb:disable all", () => { + const source = dedent` + <%# herb:disable all %> +
+ content +
+ ` + + const result = formatter.format(source) + expect(result).not.toBe(source) + }) + + test("should ignore formatting when directive is at end of file", () => { + const source = dedent` +
+ content +
+ <%# herb:formatter ignore %> + ` + + const result = formatter.format(source) + expect(result).toBe(source) + }) + + test("should ignore formatting when directive is nested deep in document", () => { + const source = dedent` +
+
+
+ <%# herb:formatter ignore %> + Badly formatted +
+
+
+ ` + + const result = formatter.format(source) + expect(result).toBe(source) + }) +}) diff --git a/javascript/packages/linter/README.md b/javascript/packages/linter/README.md index f17240074..dc19d6a41 100644 --- a/javascript/packages/linter/README.md +++ b/javascript/packages/linter/README.md @@ -349,6 +349,36 @@ To disable all linting rules for a specific line, use `all`: Inline disable comments only affect the line they appear on. Each line that needs linting disabled must have its own disable comment. ::: +### Disabling Linting for Entire Files + +You can disable linting for an entire file by adding the `ignore` directive anywhere in your template: + +```erb +<%# herb:linter ignore %> +``` + +**Example:** + +:::code-group +```erb [ignored.html.erb] +<%# herb:linter ignore %> + +
+ This entire file will not be linted +
+``` + +```erb [regular.html.erb] +
+ This entire file will be linted +
+``` +::: + +::: warning Important +The `<%# herb:linter ignore %>` directive must be an exact match. Extra text or spacing will prevent it from working. +::: + ## Configuration Create a `.herb.yml` file in your project root to configure the linter: diff --git a/javascript/packages/linter/src/herb-disable-comment-utils.ts b/javascript/packages/linter/src/herb-disable-comment-utils.ts index 89375af69..a6daa27fe 100644 --- a/javascript/packages/linter/src/herb-disable-comment-utils.ts +++ b/javascript/packages/linter/src/herb-disable-comment-utils.ts @@ -2,6 +2,9 @@ * Utilities for parsing herb:disable comments */ +import { isERBCommentNode } from "@herb-tools/core" +import type { Node } from "@herb-tools/core" + /** * Information about a single rule name in a herb:disable comment */ diff --git a/javascript/packages/linter/src/linter-ignore.ts b/javascript/packages/linter/src/linter-ignore.ts new file mode 100644 index 000000000..587583621 --- /dev/null +++ b/javascript/packages/linter/src/linter-ignore.ts @@ -0,0 +1,56 @@ +import { isERBCommentNode } from "@herb-tools/core" +import { Visitor, ERBContentNode } from "@herb-tools/core" + +import type { Node, ParseResult } from "@herb-tools/core" + +const HERB_LINTER_PREFIX = "herb:linter" +const HERB_LINTER_IGNORE_PREFIX = `${HERB_LINTER_PREFIX} ignore` + +/** + * Check if an ERB content node is a herb:linter ignore comment. + * + * @param node - The ERB content node to check + * @returns true if this is a linter ignore directive + */ +export function isHerbLinterIgnoreComment(node: Node): boolean { + if (!isERBCommentNode(node)) return false + + const content = node?.content?.value || "" + + return content.trim() === HERB_LINTER_IGNORE_PREFIX +} + +/** + * Check if the document contains a herb:linter ignore directive anywhere. + */ +export function hasLinterIgnoreDirective(parseResult: ParseResult): boolean { + if (parseResult.failed) return false + + const detector = new LinterIgnoreDetector() + detector.visit(parseResult.value) + return detector.hasIgnoreDirective +} + +/** + * Visitor that detects if the AST contains a herb:linter ignore directive. + */ +class LinterIgnoreDetector extends Visitor { + public hasIgnoreDirective = false + + visitNode(node: Node): void { + if (this.hasIgnoreDirective) return + + this.visitChildNodes(node) + } + + visitERBContentNode(node: ERBContentNode): void { + if (isHerbLinterIgnoreComment(node)) { + this.hasIgnoreDirective = true + return + } + + if (this.hasIgnoreDirective) return + + this.visitChildNodes(node) + } +} diff --git a/javascript/packages/linter/src/linter.ts b/javascript/packages/linter/src/linter.ts index 1ef5833c9..ccc126e42 100644 --- a/javascript/packages/linter/src/linter.ts +++ b/javascript/packages/linter/src/linter.ts @@ -5,6 +5,7 @@ import { minimatch } from "minimatch" import { rules } from "./rules.js" import { findNodeByLocation } from "./rules/rule-utils.js" import { parseHerbDisableLine } from "./herb-disable-comment-utils.js" +import { hasLinterIgnoreDirective } from "./linter-ignore.js" import { ParserNoErrorsRule } from "./rules/parser-no-errors.js" import { DEFAULT_RULE_CONFIG } from "./types.js" @@ -303,6 +304,18 @@ export class Linter { let wouldBeIgnoredCount = 0 const parseResult = this.herb.parse(source, { track_whitespace: true }) + + // Check for file-level ignore directive using visitor + if (hasLinterIgnoreDirective(parseResult)) { + return { + offenses: [], + errors: 0, + warnings: 0, + info: 0, + hints: 0, + ignored: 0 + } + } const lexResult = this.herb.lex(source) const hasParserErrors = parseResult.recursiveErrors().length > 0 const sourceLines = source.split("\n") @@ -573,3 +586,4 @@ export class Linter { } } } + diff --git a/javascript/packages/linter/test/herb-linter-ignore.test.ts b/javascript/packages/linter/test/herb-linter-ignore.test.ts new file mode 100644 index 000000000..58994f3f7 --- /dev/null +++ b/javascript/packages/linter/test/herb-linter-ignore.test.ts @@ -0,0 +1,96 @@ +import dedent from "dedent" +import { describe, test } from "vitest" +import { HTMLTagNameLowercaseRule } from "../src/rules/html-tag-name-lowercase.js" +import { createLinterTest } from "./helpers/linter-test-helper.js" + +const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLTagNameLowercaseRule) + +describe("herb:linter ignore directive", () => { + test("should ignore all linting when directive is at top of file", () => { + expectNoOffenses(dedent` + <%# herb:linter ignore %> +
+ content +
+ `) + }) + + test("should ignore linting when directive is in middle of file", () => { + expectNoOffenses(dedent` +
+ <%# herb:linter ignore %> + content +
+ `) + }) + + test("should work with empty lines before directive", () => { + expectNoOffenses(` + + <%# herb:linter ignore %> +
+ content +
+ `) + }) + + test("should not match herb:linter ignore with extra text", () => { + expectError('Opening tag name `
` should be lowercase. Use `
` instead.') + expectError('Opening tag name `` should be lowercase. Use `` instead.') + expectError('Closing tag name `` should be lowercase. Use `` instead.') + expectError('Closing tag name `
` should be lowercase. Use `
` instead.') + + assertOffenses(dedent` + <%# herb:linter ignore some-rule %> +
+ content +
+ `) + }) + + test("should not match herb:disable all", () => { + expectError('Opening tag name `
` should be lowercase. Use `
` instead.') + expectError('Opening tag name `` should be lowercase. Use `` instead.') + expectError('Closing tag name `` should be lowercase. Use `` instead.') + expectError('Closing tag name `
` should be lowercase. Use `
` instead.') + + assertOffenses(dedent` + <%# herb:disable all %> +
+ content +
+ `) + }) + + test("directive must be exact match", () => { + expectError('Opening tag name `
` should be lowercase. Use `
` instead.') + expectError('Closing tag name `
` should be lowercase. Use `
` instead.') + + assertOffenses(dedent` + <%# herb:linter ignore %> +
content
+ `) + }) + + test("should ignore linting when directive is at end of file", () => { + expectNoOffenses(dedent` +
+ content +
+ <%# herb:linter ignore %> + `) + }) + + test("should ignore linting when directive is nested deep in document", () => { + expectNoOffenses(dedent` +
+
+
+ <%# herb:linter ignore %> + content +
+
+
+ `) + }) +})