diff --git a/.github/labeler.yml b/.github/labeler.yml index eeb52e2e2..92ab37f23 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -146,6 +146,11 @@ node: - any-glob-to-any-file: - '**/javascript/packages/node/**/*' +turbo-lint: + - changed-files: + - any-glob-to-any-file: + - '**/javascript/packages/turbo-lint/**/*' + stimulus-lint: - changed-files: - any-glob-to-any-file: diff --git a/javascript/packages/turbo-lint/README.md b/javascript/packages/turbo-lint/README.md new file mode 100644 index 000000000..b120bddbb --- /dev/null +++ b/javascript/packages/turbo-lint/README.md @@ -0,0 +1,39 @@ +# Turbo Lint + +**Package**: [`turbo-lint`](https://www.npmjs.com/package/turbo-lint) + +--- + +Linter and static code analyzer for [Turbo](https://turbo.hotwired.dev/), powered by [Herb](https://github.com/marcoroth/herb). + +### Installation + +:::code-group + +```shell [npm] +npm install turbo-lint +``` + +```shell [yarn] +yarn add turbo-lint +``` + +```shell [pnpm] +pnpm add turbo-lint +``` + +```shell [bun] +bun add turbo-lint +``` +::: + + +### Run + +```bash +npx turbo-lint +``` + +## Rules + +Rules are documented [here](./docs/rules/README.md) diff --git a/javascript/packages/turbo-lint/bin/turbo-lint b/javascript/packages/turbo-lint/bin/turbo-lint new file mode 100755 index 000000000..631e19e43 --- /dev/null +++ b/javascript/packages/turbo-lint/bin/turbo-lint @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import("../dist/turbo-lint.js") diff --git a/javascript/packages/turbo-lint/docs/rules/README.md b/javascript/packages/turbo-lint/docs/rules/README.md new file mode 100644 index 000000000..b28b55ce8 --- /dev/null +++ b/javascript/packages/turbo-lint/docs/rules/README.md @@ -0,0 +1,7 @@ +# Linter Rules + +This page contains documentation for all Turbo Linter rules. + +## Available Rules + +- [`turbo-permanent`](./html-turbo-permanent.md) - Prevents invalid usage of the boolean attribute diff --git a/javascript/packages/turbo-lint/docs/rules/html-turbo-permanent.md b/javascript/packages/turbo-lint/docs/rules/html-turbo-permanent.md new file mode 100644 index 000000000..0cebda8a8 --- /dev/null +++ b/javascript/packages/turbo-lint/docs/rules/html-turbo-permanent.md @@ -0,0 +1,32 @@ +# Linter Rule: HTML Turbo permanent attribute usage + +**Rule:** `html-turbo-permanent` + +## Description + +Ensure that turbo permanent attributes are used correctly in HTML elements. The `data-turbo-permanent` attribute is used to mark elements that should persist across page navigations in Turbo Drive. + +## Rationale + +Data turbo permanent is active if the attribute is present, `data-turbo-permanent="false"` behaves the same as `data-turbo-permanent`. This should be disallowed to avoid confusion. + +## Examples + +### ✅ Good + +```html +
1 item
+``` + +### 🚫 Bad + +```html +
1 item
+
1 item
+
1 item
+``` + +## References + +* [Turbo: `data-turbo-permanent` attribute](https://turbo.hotwired.dev/handbook/drive#permanent-elements) +* [HTML: Boolean Attributes](https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML) diff --git a/javascript/packages/turbo-lint/package.json b/javascript/packages/turbo-lint/package.json new file mode 100644 index 000000000..76250a1fb --- /dev/null +++ b/javascript/packages/turbo-lint/package.json @@ -0,0 +1,50 @@ +{ + "name": "turbo-lint", + "version": "0.1.0", + "description": "Linting rules for Turbo HTML+ERB view templates.", + "license": "MIT", + "homepage": "https://herb-tools.dev", + "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60turbo-lint%60:%20", + "repository": { + "type": "git", + "url": "https://github.com/marcoroth/herb.git", + "directory": "javascript/packages/turbo-lint" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "turbo-lint": "./bin/turbo-lint" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "clean": "rimraf dist", + "build": "yarn clean && tsc -b && rollup -c", + "watch": "tsc -b -w", + "test": "vitest run", + "test:watch": "vitest --watch", + "prepublishOnly": "yarn clean && yarn build && yarn test" + }, + "dependencies": { + "@herb-tools/core": "0.7.5", + "@herb-tools/highlighter": "0.7.5", + "@herb-tools/linter": "0.7.5", + "@herb-tools/node-wasm": "0.7.5" + }, + "files": [ + "package.json", + "README.md", + "docs/", + "src/", + "bin/", + "dist/" + ] +} diff --git a/javascript/packages/turbo-lint/project.json b/javascript/packages/turbo-lint/project.json new file mode 100644 index 000000000..897c4e95c --- /dev/null +++ b/javascript/packages/turbo-lint/project.json @@ -0,0 +1,33 @@ +{ + "name": "turbo-lint", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "javascript/packages/turbo-lint/src", + "projectType": "library", + "targets": { + "build": { + "executor": "nx:run-script", + "options": { + "script": "build" + }, + "dependsOn": [ + "@herb-tools/core:build", + "@herb-tools/node-wasm:build", + "@herb-tools/linter:build", + "@herb-tools/highlighter:build" + ] + }, + "test": { + "executor": "nx:run-script", + "options": { + "script": "test" + } + }, + "clean": { + "executor": "nx:run-script", + "options": { + "script": "clean" + } + } + }, + "tags": [] +} diff --git a/javascript/packages/turbo-lint/rollup.config.mjs b/javascript/packages/turbo-lint/rollup.config.mjs new file mode 100644 index 000000000..32bbe5254 --- /dev/null +++ b/javascript/packages/turbo-lint/rollup.config.mjs @@ -0,0 +1,85 @@ +import typescript from "@rollup/plugin-typescript" +import { nodeResolve } from "@rollup/plugin-node-resolve" +import json from "@rollup/plugin-json" +import commonjs from "@rollup/plugin-commonjs" + +// Bundle the CLI entry point into a single CommonJS file. +// Exclude Node built-in so they remain as externals. +const external = [ + "path", + "url", + "fs", + "module", +] + +function isExternal(id) { + return ( + external.includes(id) || + external.some((pkg) => id === pkg || id.startsWith(pkg + "/")) + ) +} + +export default [ + // CLI entry point (CommonJS) + { + input: "src/turbo-lint.ts", + output: { + file: "dist/turbo-lint.js", + format: "cjs", + sourcemap: true, + }, + external: isExternal, + plugins: [ + nodeResolve(), + commonjs(), + json(), + typescript({ + tsconfig: "./tsconfig.json", + rootDir: "src/", + module: "esnext", + }), + ], + }, + + // Library exports (ESM) + { + input: "src/index.ts", + output: { + file: "dist/index.js", + format: "esm", + sourcemap: true, + }, + external: ["@herb-tools/core", "@herb-tools/linter", "@herb-tools/node-wasm", "turbo-parser"], + plugins: [ + nodeResolve(), + json(), + typescript({ + tsconfig: "./tsconfig.json", + declaration: true, + declarationDir: "./dist/types", + rootDir: "src/", + }), + ], + }, + + // Library exports (CommonJS) + { + input: "src/index.ts", + external: ["@herb-tools/core", "@herb-tools/linter", "@herb-tools/node-wasm", "turbo-parser"], + output: { + file: "dist/index.cjs", + format: "cjs", + sourcemap: true, + }, + plugins: [ + nodeResolve(), + commonjs(), + json(), + typescript({ + tsconfig: "./tsconfig.json", + rootDir: "src/", + module: "esnext", + }), + ], + }, +] diff --git a/javascript/packages/turbo-lint/src/cli.ts b/javascript/packages/turbo-lint/src/cli.ts new file mode 100644 index 000000000..d072c6287 --- /dev/null +++ b/javascript/packages/turbo-lint/src/cli.ts @@ -0,0 +1,38 @@ +import { Herb } from "@herb-tools/node-wasm" +import { TurboLinter } from "./linter.js" +import { defaultRules } from "./default-rules.js" + +export class CLI { + async run() { + console.log("Turbo Lint CLI") + + const args = process.argv.slice(2) + + if (args.length === 0) { + console.log("Usage: turbo-lint ") + process.exit(1) + } + + const filePath = args[0] + + // Load the Herb backend + await Herb.load() + + const linter = new TurboLinter(Herb, defaultRules) + + try { + const result = await linter.lintFile(filePath) + + console.log(`\nFound ${result.errors} errors and ${result.warnings} warnings\n`) + + for (const offense of result.offenses) { + console.log(`${filePath} - ${offense.severity}: ${offense.message} (${offense.rule})`) + } + + process.exit(result.errors > 0 ? 1 : 0) + } catch (error) { + console.error("Error:", error) + process.exit(1) + } + } +} diff --git a/javascript/packages/turbo-lint/src/default-rules.ts b/javascript/packages/turbo-lint/src/default-rules.ts new file mode 100644 index 000000000..45f2f0b23 --- /dev/null +++ b/javascript/packages/turbo-lint/src/default-rules.ts @@ -0,0 +1,10 @@ +import { HtmlTurboPermanentRule } from "./rules/html-turbo-permanent.js" + +import type { RuleClass } from "./types.js" + +/** + * Default set of Turbo linting rules + */ +export const defaultRules: RuleClass[] = [ + HtmlTurboPermanentRule +] \ No newline at end of file diff --git a/javascript/packages/turbo-lint/src/index.ts b/javascript/packages/turbo-lint/src/index.ts new file mode 100644 index 000000000..8fcaae0ec --- /dev/null +++ b/javascript/packages/turbo-lint/src/index.ts @@ -0,0 +1,5 @@ +export { TurboLinter } from "./linter.js" +export { defaultRules } from "./default-rules.js" + +export * from "./types.js" +export * from "./rules/index.js" \ No newline at end of file diff --git a/javascript/packages/turbo-lint/src/linter.ts b/javascript/packages/turbo-lint/src/linter.ts new file mode 100644 index 000000000..8640b0b5b --- /dev/null +++ b/javascript/packages/turbo-lint/src/linter.ts @@ -0,0 +1,68 @@ +import { defaultRules } from "./default-rules.js" + +import type { RuleClass, LintResult, LintContext, LintOffense } from "./types.js" +import type { HerbBackend, ParseResult } from "@herb-tools/core" + +export class TurboLinter { + private herb: HerbBackend + private rules: RuleClass[] + + /** + * Creates a new TurboLinter instance. + * @param herb - The Herb backend instance for parsing HTML + * @param rules - Array of rule classes to use + */ + constructor(herb: HerbBackend, rules: RuleClass[] = defaultRules) { + this.herb = herb + this.rules = rules + } + + /** + * Lint source code with Turbo-specific context + * @param source - The source code to lint (HTML template) + * @param context - Optional context for linting + */ + lint(source: string, context?: Partial): LintResult { + const parseResult = this.herb.parse(source) as ParseResult + + const allOffenses: LintOffense[] = [] + + // Instantiate and run each rule + for (const RuleConstructor of this.rules) { + const rule = new RuleConstructor() + const offenses = rule.check(parseResult, context) + allOffenses.push(...offenses) + } + + // Count offense severities + const errors = allOffenses.filter(o => o.severity === "error").length + const warnings = allOffenses.filter(o => o.severity === "warning").length + + return { + offenses: allOffenses, + errors, + warnings + } + } + + /** + * Get the number of rules loaded in the linter + */ + getRuleCount(): number { + return this.rules.length + } + + /** + * Lint an HTML file + */ + async lintFile(filePath: string): Promise { + const fs = await import("fs") + const source = fs.readFileSync(filePath, "utf-8") + + const context: Partial = { + fileName: filePath + } + + return this.lint(source, context) + } +} diff --git a/javascript/packages/turbo-lint/src/rules/html-turbo-permanent.ts b/javascript/packages/turbo-lint/src/rules/html-turbo-permanent.ts new file mode 100644 index 000000000..9944e8558 --- /dev/null +++ b/javascript/packages/turbo-lint/src/rules/html-turbo-permanent.ts @@ -0,0 +1,47 @@ +import { getTagName, hasAttribute, getAttributeValue, findAttributeByName, getAttributes } from "./rule-utils.js" +import { ParserRule } from "../types.js" +import { Visitor } from "@herb-tools/core" + +import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core" +import type { LintContext, LintOffense } from "../types.js" + +class HTMLTurboPermanentVisitor extends Visitor { + public readonly offenses: LintOffense[] = [] + private ruleName: string + + constructor(ruleName: string) { + super() + this.ruleName = ruleName + } + + visitHTMLOpenTagNode(node: HTMLOpenTagNode): void { + this.checkTurboPermanentAttribute(node) + super.visitHTMLOpenTagNode(node) + } + + private checkTurboPermanentAttribute(node: HTMLOpenTagNode): void { + const attribute = findAttributeByName(getAttributes(node), "data-turbo-permanent") + if (!attribute) return + + if (getAttributeValue(attribute) !== null) { + this.offenses.push({ + rule: this.ruleName, + code: this.ruleName, + source: "Turbo Linter", + message: 'Attribute `data-turbo-permanent` should not contain any value', + location: node.tag_name!.location, + severity: "error" + }) + } + } +} + +export class HtmlTurboPermanentRule extends ParserRule { + name = "html-turbo-permanent" + + check(result: ParseResult, context?: Partial): LintOffense[] { + const visitor = new HTMLTurboPermanentVisitor(this.name) + visitor.visit(result.value) + return visitor.offenses + } +} diff --git a/javascript/packages/turbo-lint/src/rules/index.ts b/javascript/packages/turbo-lint/src/rules/index.ts new file mode 100644 index 000000000..653553a1a --- /dev/null +++ b/javascript/packages/turbo-lint/src/rules/index.ts @@ -0,0 +1 @@ +export { HtmlTurboPermanentRule } from "./html-turbo-permanent.js" \ No newline at end of file diff --git a/javascript/packages/turbo-lint/src/rules/rule-utils.ts b/javascript/packages/turbo-lint/src/rules/rule-utils.ts new file mode 100644 index 000000000..689defc17 --- /dev/null +++ b/javascript/packages/turbo-lint/src/rules/rule-utils.ts @@ -0,0 +1,161 @@ +import { + getStaticAttributeName, + hasDynamicAttributeName as hasNodeDynamicAttributeName, + getCombinedAttributeName, + hasERBOutput, + getStaticContentFromNodes, + hasStaticContent, + isEffectivelyStatic, + getValidatableStaticContent +} from "@herb-tools/core" + +import type { + ERBContentNode, + HTMLAttributeNameNode, + HTMLAttributeNode, + HTMLAttributeValueNode, + HTMLElementNode, + HTMLOpenTagNode, + LiteralNode, + LexResult, + Token, + Node +} from "@herb-tools/core" + +import type * as Nodes from "@herb-tools/core" + +/** + * Gets attributes from an HTMLOpenTagNode + */ +export function getAttributes(node: HTMLOpenTagNode): HTMLAttributeNode[] { + return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE") as HTMLAttributeNode[] +} + +/** + * Gets the tag name from an HTML tag node (lowercased) + */ +export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): string | null { + if (!node) return null + + return node.tag_name?.value.toLowerCase() || null +} + +/** + * Gets the attribute name from an HTMLAttributeNode (lowercased) + * Returns null if the attribute name contains dynamic content (ERB) + */ +export function getAttributeName(attributeNode: HTMLAttributeNode, lowercase = true): string | null { + if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") { + const nameNode = attributeNode.name as HTMLAttributeNameNode + const staticName = getStaticAttributeName(nameNode) + + if (!lowercase) return staticName + + return staticName ? staticName.toLowerCase() : null + } + + return null +} + +/** + * Checks if an attribute has a dynamic (ERB-containing) name + */ +export function hasDynamicAttributeName(attributeNode: HTMLAttributeNode): boolean { + if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") { + const nameNode = attributeNode.name as HTMLAttributeNameNode + return hasNodeDynamicAttributeName(nameNode) + } + + return false +} + +/** + * Gets the combined string representation of an attribute name (for debugging) + * This includes both static content and ERB syntax + */ +export function getCombinedAttributeNameString(attributeNode: HTMLAttributeNode): string { + if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") { + const nameNode = attributeNode.name as HTMLAttributeNameNode + + return getCombinedAttributeName(nameNode) + } + + return "" +} + +/** + * Gets the attribute value content from an HTMLAttributeValueNode + */ +export function getAttributeValue(attributeNode: HTMLAttributeNode): string | null { + const valueNode: HTMLAttributeValueNode | null = attributeNode.value as HTMLAttributeValueNode + + if (valueNode === null) return null + + if (valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE" || !valueNode.children?.length) { + return null + } + + let result = "" + + for (const child of valueNode.children) { + switch (child.type) { + case "AST_ERB_CONTENT_NODE": { + const erbNode = child as ERBContentNode + + if (erbNode.content) { + result += `${erbNode.tag_opening?.value}${erbNode.content.value}${erbNode.tag_closing?.value}` + } + + break + } + + case "AST_LITERAL_NODE": { + result += (child as LiteralNode).content + break + } + } + } + + return result +} + +/** + * Checks if an attribute has a value + */ +export function hasAttributeValue(attributeNode: HTMLAttributeNode): boolean { + return attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE" +} + +/** + * Finds an attribute by name in a list of attributes + */ +export function findAttributeByName(attributes: Node[], attributeName: string): HTMLAttributeNode | null { + for (const child of attributes) { + if (child.type === "AST_HTML_ATTRIBUTE_NODE") { + const attributeNode = child as HTMLAttributeNode + const name = getAttributeName(attributeNode) + + if (name === attributeName.toLowerCase()) { + return attributeNode + } + } + } + + return null +} + +/** + * Checks if a tag has a specific attribute + */ +export function hasAttribute(node: HTMLOpenTagNode, attributeName: string): boolean { + return getAttribute(node, attributeName) !== null +} + +/** + * Checks if a tag has a specific attribute + */ +export function getAttribute(node: HTMLOpenTagNode, attributeName: string): HTMLAttributeNode | null { + const attributes = getAttributes(node) + + return findAttributeByName(attributes, attributeName) +} \ No newline at end of file diff --git a/javascript/packages/turbo-lint/src/turbo-lint.ts b/javascript/packages/turbo-lint/src/turbo-lint.ts new file mode 100644 index 000000000..dd8f1b5c3 --- /dev/null +++ b/javascript/packages/turbo-lint/src/turbo-lint.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { CLI } from "./cli.js" + +const cli = new CLI() +cli.run() diff --git a/javascript/packages/turbo-lint/src/types.ts b/javascript/packages/turbo-lint/src/types.ts new file mode 100644 index 000000000..9bc214666 --- /dev/null +++ b/javascript/packages/turbo-lint/src/types.ts @@ -0,0 +1,32 @@ +import type { Herb } from "@herb-tools/node-wasm" +import type { ParseResult, Location, Diagnostic } from "@herb-tools/core" + +export type HerbType = typeof Herb + +export type LintSeverity = "error" | "warning" | "info" | "hint" + +export interface LintContext { + fileName?: string +} + +export interface LintOffense extends Diagnostic { + rule: string + severity: LintSeverity +} + +export interface LintResult { + offenses: LintOffense[] + errors: number + warnings: number +} + +export interface LinterConfig { + rules?: Record +} + +export abstract class ParserRule { + abstract name: string + abstract check(result: ParseResult, context?: Partial): LintOffense[] +} + +export type RuleClass = new () => ParserRule \ No newline at end of file diff --git a/javascript/packages/turbo-lint/test/basic.test.ts b/javascript/packages/turbo-lint/test/basic.test.ts new file mode 100644 index 000000000..82ecc2a90 --- /dev/null +++ b/javascript/packages/turbo-lint/test/basic.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeAll } from "vitest" +import { Herb } from "@herb-tools/node-wasm" +import { TurboLinter, defaultRules } from "../src/index.js" + +describe("TurboLinter basic test", () => { + beforeAll(async () => { + await Herb.load() + }) + + it("should create linter instance", () => { + const linter = new TurboLinter(Herb, defaultRules) + expect(linter).toBeDefined() + expect(linter.getRuleCount()).toBeGreaterThan(0) + }) + + it("should lint turbo templates", () => { + const linter = new TurboLinter(Herb, defaultRules) + + const source = ` +
+ 1 item +
+ ` + + const result = linter.lint(source) + expect(result).toBeDefined() + expect(result.offenses).toBeDefined() + expect(result.offenses).toHaveLength(0) + }) + + it("should detect turbo-permanent issues", () => { + const linter = new TurboLinter(Herb, defaultRules) + + const source = ` +
+ 1 item +
+ ` + + const result = linter.lint(source) + expect(result).toBeDefined() + expect(result.offenses).toBeDefined() + expect(result.offenses).toHaveLength(1) + expect(result.offenses[0].rule).toBe("html-turbo-permanent") + }) +}) diff --git a/javascript/packages/turbo-lint/test/helpers/linter-test-helper.ts b/javascript/packages/turbo-lint/test/helpers/linter-test-helper.ts new file mode 100644 index 000000000..3f2bac811 --- /dev/null +++ b/javascript/packages/turbo-lint/test/helpers/linter-test-helper.ts @@ -0,0 +1,271 @@ +import { beforeAll, afterEach, expect } from "vitest" + +import { Herb } from "@herb-tools/node-wasm" +import { TurboLinter } from "../../src/linter.js" + +import type { RuleClass } from "../../src/types.js" + +interface ExpectedLocation { + line?: number + column?: number +} + +type LocationInput = ExpectedLocation | [number, number] | [number] + +interface ExpectedOffense { + message: string + location?: ExpectedLocation +} + +interface TestOptions { + context?: any + allowInvalidSyntax?: boolean +} + +interface LinterTestHelpers { + expectNoOffenses: (html: string, options?: any | TestOptions) => void + expectWarning: (message: string, location?: LocationInput) => void + expectError: (message: string, location?: LocationInput) => void + assertOffenses: (html: string, options?: any | TestOptions) => void +} + +/** + * Creates a test helper for linter rules that reduces boilerplate in tests. + */ +export function createLinterTest(rules: RuleClass | RuleClass[]): LinterTestHelpers { + const expectedWarnings: ExpectedOffense[] = [] + const expectedErrors: ExpectedOffense[] = [] + let hasAsserted = false + + const ruleClasses = Array.isArray(rules) ? rules : [rules] + const primaryRuleClass = ruleClasses[0] + const ruleInstance = new primaryRuleClass() + const isParserNoErrorsRule = ruleInstance.name === "parser-no-errors" + + beforeAll(async () => { + await Herb.load() + }) + + afterEach(() => { + if (!hasAsserted && (expectedWarnings.length > 0 || expectedErrors.length > 0)) { + const pendingCount = expectedWarnings.length + expectedErrors.length + + throw new Error( + `Test has ${pendingCount} pending expectation(s) that were never asserted. ` + + `Did you forget to call assertOffenses() or expectNoOffenses()?` + ) + } + + expectedWarnings.length = 0 + expectedErrors.length = 0 + hasAsserted = false + }) + + const expectNoOffenses = (html: string, options?: any | TestOptions) => { + if (expectedWarnings.length > 0 || expectedErrors.length > 0) { + throw new Error( + "Cannot call expectNoOffenses() after registering expectations with expectWarning() or expectError()" + ) + } + + hasAsserted = true + + const context = options?.context ?? options + const allowInvalidSyntax = options?.allowInvalidSyntax ?? false + + if (!isParserNoErrorsRule) { + const parseResult = Herb.parse(html, { track_whitespace: true }) + const parserErrors = parseResult.recursiveErrors() + + if (allowInvalidSyntax && parserErrors.length === 0) { + throw new Error( + `Test has 'allowInvalidSyntax: true' but the HTML is actually valid.\n` + + `Remove the 'allowInvalidSyntax' option since the HTML parses without errors.\n` + + `Source:\n${html}` + ) + } + + if (!allowInvalidSyntax && parserErrors.length > 0) { + const formattedErrors = parserErrors.map(error => ` - ${error.message} (${error.type}) at ${error.location.start.line}:${error.location.start.column}`).join('\n') + + throw new Error( + `Test HTML has parser errors. Fix the HTML before testing the linter rule.\n` + + `Source:\n${html}\n\n` + + `Parser errors:\n${formattedErrors}` + ) + } + } + + const linter = new TurboLinter(Herb, ruleClasses) + const lintResult = linter.lint(html, context) + + const ruleName = ruleInstance.name + const primaryOffenses = lintResult.offenses.filter(offense => offense.rule === ruleName) + + expect(primaryOffenses).toHaveLength(0) + } + + const normalizeLocation = (location?: LocationInput): ExpectedLocation | undefined => { + if (!location) return undefined + + if (Array.isArray(location)) { + return location.length === 2 + ? { line: location[0], column: location[1] } + : { line: location[0] } + } + return location + } + + const expectWarning = (message: string, location?: LocationInput) => { + expectedWarnings.push({ message, location: normalizeLocation(location) }) + } + + const expectError = (message: string, location?: LocationInput) => { + expectedErrors.push({ message, location: normalizeLocation(location) }) + } + + const assertOffenses = (html: string, options?: any | TestOptions) => { + if (expectedWarnings.length === 0 && expectedErrors.length === 0) { + throw new Error( + "Cannot call assertOffenses() with no expectations. Use expectNoOffenses() instead." + ) + } + + hasAsserted = true + + const context = options?.context ?? options + const allowInvalidSyntax = options?.allowInvalidSyntax ?? false + + if (!isParserNoErrorsRule) { + const parseResult = Herb.parse(html, { track_whitespace: true }) + const parserErrors = parseResult.recursiveErrors() + + if (allowInvalidSyntax && parserErrors.length === 0) { + throw new Error( + `Test has 'allowInvalidSyntax: true' but the HTML is actually valid.\n` + + `Remove the 'allowInvalidSyntax' option since the HTML parses without errors.\n` + + `Source:\n${html}` + ) + } + + if (!allowInvalidSyntax && parserErrors.length > 0) { + const formattedErrors = parserErrors.map(error => ` - ${error.message} (${error.type}) at ${error.location.start.line}:${error.location.start.column}`).join('\n') + + throw new Error( + `Test HTML has parser errors. Fix the HTML before testing the linter rule.\n` + + `Source:\n${html}\n\n` + + `Parser errors:\n${formattedErrors}` + ) + } + } + + const linter = new TurboLinter(Herb, ruleClasses) + const lintResult = linter.lint(html, context) + const ruleName = ruleInstance.name + + const primaryOffenses = lintResult.offenses.filter(o => o.rule === ruleName) + const primaryErrors = primaryOffenses.filter(o => o.severity === "error") + const primaryWarnings = primaryOffenses.filter(o => o.severity === "warning") + + if (primaryErrors.length !== expectedErrors.length) { + throw new Error( + `Expected ${expectedErrors.length} error(s) from rule "${ruleName}" but found ${primaryErrors.length}.\n` + + `Expected:\n${expectedErrors.map(e => ` - "${e.message}"`).join('\n')}\n` + + `Actual:\n${primaryErrors.map(o => ` - "${o.message}" at ${o.location.start.line}:${o.location.start.column}`).join('\n')}` + ) + } + + if (primaryWarnings.length !== expectedWarnings.length) { + throw new Error( + `Expected ${expectedWarnings.length} warning(s) from rule "${ruleName}" but found ${primaryWarnings.length}.\n` + + `Expected:\n${expectedWarnings.map(w => ` - "${w.message}"`).join('\n')}\n` + + `Actual:\n${primaryWarnings.map(o => ` - "${o.message}" at ${o.location.start.line}:${o.location.start.column}`).join('\n')}` + ) + } + + primaryOffenses.forEach(offense => { + expect(offense.rule).toBe(ruleName) + }) + + const actualErrors = primaryErrors + const actualWarnings = primaryWarnings + + matchOffenses(expectedErrors, actualErrors, "error") + matchOffenses(expectedWarnings, actualWarnings, "warning") + + expectedWarnings.length = 0 + expectedErrors.length = 0 + } + + return { + expectNoOffenses, + expectWarning, + expectError, + assertOffenses + } +} + +/** + * Matches expected offenses to actual offenses in an order-independent way + */ +function matchOffenses( + expected: ExpectedOffense[], + actual: any[], + severity: "error" | "warning" +) { + const unmatched = [...expected] + const unmatchedActual = [...actual] + + for (const actualOffense of actual) { + const matchIndex = unmatched.findIndex(exp => { + if (exp.message !== actualOffense.message) { + return false + } + + if (exp.location?.line !== undefined && exp.location.line !== actualOffense.location.start.line) { + return false + } + + if (exp.location?.column !== undefined && exp.location.column !== actualOffense.location.start.column) { + return false + } + + return true + }) + + if (matchIndex !== -1) { + unmatched.splice(matchIndex, 1) + const actualIndex = unmatchedActual.findIndex(o => o === actualOffense) + if (actualIndex !== -1) { + unmatchedActual.splice(actualIndex, 1) + } + } + } + + if (unmatched.length > 0 || unmatchedActual.length > 0) { + const errors: string[] = [] + + if (unmatched.length > 0) { + errors.push(`Expected ${severity}(s) not found:`) + unmatched.forEach(exp => { + const location = exp.location?.line !== undefined + ? exp.location?.column !== undefined + ? ` at ${exp.location.line}:${exp.location.column}` + : ` at line ${exp.location.line}` + : "" + errors.push(` - "${exp.message}"${location}`) + }) + } + + if (unmatchedActual.length > 0) { + errors.push(`Unexpected ${severity}(s) found:`) + unmatchedActual.forEach(offense => { + errors.push( + ` - "${offense.message}" at ${offense.location.start.line}:${offense.location.start.column}` + ) + }) + } + + throw new Error(errors.join("\n")) + } +} \ No newline at end of file diff --git a/javascript/packages/turbo-lint/test/rules/html-turbo-permanent.test.ts b/javascript/packages/turbo-lint/test/rules/html-turbo-permanent.test.ts new file mode 100644 index 000000000..c0d7e306a --- /dev/null +++ b/javascript/packages/turbo-lint/test/rules/html-turbo-permanent.test.ts @@ -0,0 +1,26 @@ +import { describe, test } from "vitest" +import { HtmlTurboPermanentRule } from "../../src/rules/html-turbo-permanent.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HtmlTurboPermanentRule) + +describe("html-turbo-permanent", () => { + test("passes when no explicit value is given", () => { + expectNoOffenses('
1 item
') + }) + + test("fails when string true value is given", () => { + expectError('Attribute `data-turbo-permanent` should not contain any value') + assertOffenses('
1 item
') + }) + + test("fails when passing permanent=false", () => { + expectError('Attribute `data-turbo-permanent` should not contain any value') + assertOffenses('
1 item
') + }) + + test("fails when other value is passed", () => { + expectError('Attribute `data-turbo-permanent` should not contain any value') + assertOffenses('
1 item
') + }) +}) diff --git a/javascript/packages/turbo-lint/test/test.html b/javascript/packages/turbo-lint/test/test.html new file mode 100644 index 000000000..f0cf79b7e --- /dev/null +++ b/javascript/packages/turbo-lint/test/test.html @@ -0,0 +1 @@ +
Test
diff --git a/javascript/packages/turbo-lint/test/valid.html b/javascript/packages/turbo-lint/test/valid.html new file mode 100644 index 000000000..8e30faca1 --- /dev/null +++ b/javascript/packages/turbo-lint/test/valid.html @@ -0,0 +1 @@ +
Valid
diff --git a/javascript/packages/turbo-lint/tsconfig.json b/javascript/packages/turbo-lint/tsconfig.json new file mode 100644 index 000000000..b36bf65cd --- /dev/null +++ b/javascript/packages/turbo-lint/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist/types", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "sourceMap": true + }, + "include": ["src/**/*", "types/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/workspace.json b/workspace.json index d828e4849..562cc41e8 100644 --- a/workspace.json +++ b/workspace.json @@ -15,6 +15,7 @@ "@herb-tools/rewriter": "javascript/packages/rewriter", "@herb-tools/tailwind-class-sorter": "javascript/packages/tailwind-class-sorter", "stimulus-lint": "javascript/packages/stimulus-lint", + "turbo-lint": "javascript/packages/turbo-lint", "herb-language-server": "javascript/packages/herb-language-server", "vscode": "javascript/packages/vscode", "docs": "./docs",