diff --git a/@commitlint/rules/src/body-max-line-length.test.ts b/@commitlint/rules/src/body-max-line-length.test.ts index afd4cbe5d8..b0f6238b67 100644 --- a/@commitlint/rules/src/body-max-line-length.test.ts +++ b/@commitlint/rules/src/body-max-line-length.test.ts @@ -5,6 +5,7 @@ import { bodyMaxLineLength } from "./body-max-line-length.js"; const short = "a"; const long = "ab"; +const longFooter = `Signed-off-by: ${"a".repeat(110)}`; const url = "https://example.com/URL/with/a/very/long/path"; const value = short.length; @@ -13,6 +14,7 @@ const messages = { empty: "test: subject", short: `test: subject\n${short}`, long: `test: subject\n${long}`, + longFooter: `test: subject\n\n${short}\n\n${longFooter}`, shortMultipleLines: `test:subject\n${short}\n${short}\n${short}`, longMultipleLines: `test:subject\n${short}\n${long}\n${short}`, urlStandalone: `test:subject\n${short}\n${url}\n${short}`, @@ -57,6 +59,12 @@ test("with long should fail", async () => { expect(actual).toEqual(expected); }); +test("with long footer should succeed", async () => { + const [actual] = bodyMaxLineLength(await parsed.longFooter, undefined, value); + const expected = true; + expect(actual).toEqual(expected); +}); + test("with short with multiple lines should succeed", async () => { const [actual] = bodyMaxLineLength(await parsed.shortMultipleLines, undefined, value); const expected = true; diff --git a/@commitlint/rules/src/body-max-line-length.ts b/@commitlint/rules/src/body-max-line-length.ts index 97dd32814a..f89eeea531 100644 --- a/@commitlint/rules/src/body-max-line-length.ts +++ b/@commitlint/rules/src/body-max-line-length.ts @@ -1,12 +1,49 @@ import { maxLineLength } from "@commitlint/ensure"; +import toLines from "@commitlint/to-lines"; import { SyncRule } from "@commitlint/types"; +const TRAILER_TOKEN = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+:\s\S/; + +function withoutTrailingFooter(body: string, footer?: string | null): string { + const footerStart = footer ? body.length - footer.length : -1; + const input = + footer && + footerStart >= 0 && + body.endsWith(footer) && + (footerStart === 0 || body[footerStart - 1] === "\n") + ? body.slice(0, footerStart).replace(/(\r?\n)+$/, "") + : body; + + const lines = toLines(input); + let last = lines.length - 1; + + while (last >= 0 && lines[last] === "") { + last--; + } + + let first = last; + + while (first >= 0 && lines[first] !== "") { + first--; + } + + const footerLines = lines.slice(first + 1, last + 1); + + if (footerLines.length === 0 || !footerLines.every((line) => TRAILER_TOKEN.test(line))) { + return input; + } + + return lines.slice(0, first).join("\n"); +} + export const bodyMaxLineLength: SyncRule = (parsed, _when = undefined, value = 0) => { - const input = parsed.body; + const body = parsed.body; - if (!input) { + if (!body) { return [true]; } + const input = withoutTrailingFooter(body, parsed.footer); + return [maxLineLength(input, value), `body's lines must not be longer than ${value} characters`]; };