|
| 1 | +import type { ERBNode, ParseResult } from "@herb-tools/core" |
| 2 | + |
| 3 | +import { BaseRuleVisitor } from "./rule-utils.js" |
| 4 | +import { ParserRule, BaseAutofixContext, Mutable } from "../types.js" |
| 5 | +import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js" |
| 6 | + |
| 7 | +interface ClosingErbTagIndentAutofixContext extends BaseAutofixContext { |
| 8 | + node: Mutable<ERBNode> |
| 9 | + fixType: "remove-newline" | "add-newline" | "fix-indent" |
| 10 | + expectedIndent: number |
| 11 | +} |
| 12 | + |
| 13 | +class ClosingErbTagIndentVisitor extends BaseRuleVisitor<ClosingErbTagIndentAutofixContext> { |
| 14 | + visitERBNode(node: ERBNode): void { |
| 15 | + const openTag = node.tag_opening |
| 16 | + const closeTag = node.tag_closing |
| 17 | + const content = node.content |
| 18 | + if (!openTag || !closeTag || !content) return |
| 19 | + |
| 20 | + const value = content.value |
| 21 | + if (!value.length) return |
| 22 | + |
| 23 | + const startsWithNewline = value.startsWith("\n") |
| 24 | + const endsWithNewline = this.endsWithNewline(value) |
| 25 | + |
| 26 | + if (!startsWithNewline && endsWithNewline) { |
| 27 | + this.addOffense( |
| 28 | + `Remove newline before \`${closeTag.value}\`. The opening \`${openTag.value}\` is not followed by a newline, so the closing tag should be on the same line.`, |
| 29 | + closeTag.location, |
| 30 | + { node, fixType: "remove-newline", expectedIndent: 0 } |
| 31 | + ) |
| 32 | + } else if (startsWithNewline && !endsWithNewline) { |
| 33 | + const expectedIndent = openTag.location.start.column |
| 34 | + |
| 35 | + this.addOffense( |
| 36 | + `Add newline before \`${closeTag.value}\`. The opening \`${openTag.value}\` is followed by a newline, so the closing tag should be on its own line.`, |
| 37 | + closeTag.location, |
| 38 | + { node, fixType: "add-newline", expectedIndent } |
| 39 | + ) |
| 40 | + } else if (startsWithNewline && endsWithNewline) { |
| 41 | + const expectedIndent = openTag.location.start.column |
| 42 | + const actualIndent = this.trailingIndent(value) |
| 43 | + if (actualIndent === expectedIndent) return |
| 44 | + |
| 45 | + this.addOffense( |
| 46 | + `Incorrect indentation for \`${closeTag.value}\`. Expected ${expectedIndent} ${expectedIndent === 1 ? "space" : "spaces"} but found ${actualIndent}.`, |
| 47 | + closeTag.location, |
| 48 | + { node, fixType: "fix-indent", expectedIndent } |
| 49 | + ) |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + private endsWithNewline(value: string): boolean { |
| 54 | + const lastNewlineIndex = value.lastIndexOf("\n") |
| 55 | + if (lastNewlineIndex === -1) return false |
| 56 | + |
| 57 | + const afterLastNewline = value.substring(lastNewlineIndex + 1) |
| 58 | + return afterLastNewline.length === 0 || /^\s*$/.test(afterLastNewline) |
| 59 | + } |
| 60 | + |
| 61 | + private trailingIndent(value: string): number { |
| 62 | + const lastNewlineIndex = value.lastIndexOf("\n") |
| 63 | + if (lastNewlineIndex === -1) return 0 |
| 64 | + |
| 65 | + return value.length - lastNewlineIndex - 1 |
| 66 | + } |
| 67 | +} |
| 68 | + |
| 69 | +export class ERBClosingTagIndentRule extends ParserRule<ClosingErbTagIndentAutofixContext> { |
| 70 | + static autocorrectable = true |
| 71 | + static reindentAfterAutofix = true |
| 72 | + static ruleName = "erb-closing-tag-indent" |
| 73 | + |
| 74 | + get defaultConfig(): FullRuleConfig { |
| 75 | + return { |
| 76 | + enabled: true, |
| 77 | + severity: "error" |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense<ClosingErbTagIndentAutofixContext>[] { |
| 82 | + const visitor = new ClosingErbTagIndentVisitor(this.ruleName, context) |
| 83 | + |
| 84 | + visitor.visit(result.value) |
| 85 | + |
| 86 | + return visitor.offenses |
| 87 | + } |
| 88 | + |
| 89 | + autofix(offense: LintOffense<ClosingErbTagIndentAutofixContext>, result: ParseResult, _context?: Partial<LintContext>): ParseResult | null { |
| 90 | + if (!offense.autofixContext) return null |
| 91 | + |
| 92 | + const { node, fixType, expectedIndent } = offense.autofixContext |
| 93 | + if (!node.content) return null |
| 94 | + |
| 95 | + const content = node.content.value |
| 96 | + |
| 97 | + switch (fixType) { |
| 98 | + case "add-newline": { |
| 99 | + const trimmed = content.trimEnd() |
| 100 | + node.content.value = trimmed + "\n" + " ".repeat(expectedIndent) |
| 101 | + |
| 102 | + return result |
| 103 | + } |
| 104 | + case "remove-newline": { |
| 105 | + const lastNewlineIndex = content.lastIndexOf("\n") |
| 106 | + if (lastNewlineIndex === -1) return null |
| 107 | + |
| 108 | + const beforeNewline = content.substring(0, lastNewlineIndex).trimEnd() |
| 109 | + node.content.value = beforeNewline + " " |
| 110 | + |
| 111 | + return result |
| 112 | + } |
| 113 | + case "fix-indent": { |
| 114 | + const lastNewlineIndex = content.lastIndexOf("\n") |
| 115 | + if (lastNewlineIndex === -1) return null |
| 116 | + |
| 117 | + node.content.value = content.substring(0, lastNewlineIndex + 1) + " ".repeat(expectedIndent) |
| 118 | + |
| 119 | + return result |
| 120 | + } |
| 121 | + } |
| 122 | + } |
| 123 | +} |
0 commit comments