diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index 7182a12761..583534f92b 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -193,6 +193,24 @@ test("should fail for input from stdin with rule from rc", async () => { expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); +test("should print position indicator caret by default on failure", async () => { + const cwd = await gitBootstrap("fixtures/simple"); + const result = cli(["--color=false"], { cwd })("foo: bar"); + const output = await result; + expect(output.stdout).toContain("^"); + expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); +}); + +test("should suppress position indicator when --no-show-position is set", async () => { + const cwd = await gitBootstrap("fixtures/simple"); + const result = cli(["--color=false", "--no-show-position"], { cwd })( + "foo: bar", + ); + const output = await result; + expect(output.stdout).not.toContain("^"); + expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); +}); + test("should work with --config option", async () => { const file = "config/commitlint.config.js"; const cwd = await gitBootstrap("fixtures/specify-config-file"); @@ -495,7 +513,14 @@ test("should print full commit message when input from stdin fails", async () => // output text in plain text so we can compare it const result = cli(["--color=false"], { cwd })(input); const output = await result; - expect(output.stdout.trim()).toContain(input); + // Each input line must appear in stdout *in the original order* — + // independent toContain checks would let scrambled output pass. + let cursor = 0; + for (const line of input.split("\n").filter((l) => l.length > 0)) { + const found = output.stdout.indexOf(line, cursor); + expect(found).toBeGreaterThanOrEqual(cursor); + cursor = found + line.length; + } expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); @@ -644,6 +669,7 @@ test("should print help", async () => { -q, --quiet toggle console output [boolean] [default: false] -t, --to upper end of the commit range to lint; applies if edit=false [string] -V, --verbose enable verbose output for reports without problems [boolean] + --show-position show position of error in output [boolean] [default: true] -s, --strict enable strict mode; result code 2 for warnings, 3 for errors [boolean] --options path to a JSON file or Common.js module containing CLI options -v, --version display version information [boolean] diff --git a/@commitlint/cli/src/cli.ts b/@commitlint/cli/src/cli.ts index dabedae66f..4b83404a8d 100644 --- a/@commitlint/cli/src/cli.ts +++ b/@commitlint/cli/src/cli.ts @@ -135,6 +135,11 @@ const cli = yargs(process.argv.slice(2)) type: "boolean", description: "enable verbose output for reports without problems", }, + "show-position": { + type: "boolean", + default: true, + description: "show position of error in output", + }, strict: { alias: "s", type: "boolean", @@ -398,6 +403,7 @@ async function main(args: MainArgs): Promise { color: flags.color, verbose: flags.verbose, helpUrl, + showPosition: flags["show-position"], }); if (!flags.quiet && output !== "") { diff --git a/@commitlint/cli/src/types.ts b/@commitlint/cli/src/types.ts index cbc9a8956a..032645fdf9 100644 --- a/@commitlint/cli/src/types.ts +++ b/@commitlint/cli/src/types.ts @@ -17,6 +17,7 @@ export interface CliFlags { to?: string; version?: boolean; verbose?: boolean; + "show-position"?: boolean; /** @type {'' | 'text' | 'json'} */ "print-config"?: string; strict?: boolean; diff --git a/@commitlint/config-conventional/src/index.test.ts b/@commitlint/config-conventional/src/index.test.ts index cd847ff2d3..f793c30326 100644 --- a/@commitlint/config-conventional/src/index.test.ts +++ b/@commitlint/config-conventional/src/index.test.ts @@ -132,21 +132,44 @@ test("type-enum", async () => { const result = await commitLint(messages.invalidTypeEnum); expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.typeEnum]); + expect(result.errors).toEqual([ + { + ...errors.typeEnum, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + ]); }); test("type-case", async () => { const result = await commitLint(messages.invalidTypeCase); expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.typeCase, errors.typeEnum]); + expect(result.errors).toEqual([ + { + ...errors.typeCase, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + { + ...errors.typeEnum, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + ]); }); test("type-empty", async () => { const result = await commitLint(messages.invalidTypeEmpty); expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.typeEmpty]); + expect(result.errors).toEqual([ + { + ...errors.typeEmpty, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 1, offset: 0 }, + }, + ]); }); test("subject-case", async () => { @@ -156,9 +179,23 @@ test("subject-case", async () => { ), ); - invalidInputs.forEach((result) => { + const headerPrefix = "fix(scope): "; + invalidInputs.forEach((result, i) => { + const input = messages.invalidSubjectCases[i]; + const subject = input.slice(headerPrefix.length); + const offset = headerPrefix.length; expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.subjectCase]); + expect(result.errors).toEqual([ + { + ...errors.subjectCase, + start: { line: 1, column: offset + 1, offset }, + end: { + line: 1, + column: offset + subject.length + 1, + offset: offset + subject.length, + }, + }, + ]); }); }); @@ -166,49 +203,114 @@ test("subject-empty", async () => { const result = await commitLint(messages.invalidSubjectEmpty); expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.subjectEmpty, errors.typeEmpty]); + // "fix:" — header length 4; type "fix" at offset 0 length 3. + expect(result.errors).toEqual([ + { + ...errors.subjectEmpty, + start: { line: 1, column: 5, offset: 4 }, + end: { line: 1, column: 5, offset: 4 }, + }, + { + ...errors.typeEmpty, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 1, offset: 0 }, + }, + ]); }); test("subject-full-stop", async () => { const result = await commitLint(messages.invalidSubjectFullStop); expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.subjectFullStop]); + // "fix: some message." — subject "some message." at offset 5, length 13 + // (parser keeps the trailing period in parsed.subject). + expect(result.errors).toEqual([ + { + ...errors.subjectFullStop, + start: { line: 1, column: 6, offset: 5 }, + end: { line: 1, column: 19, offset: 18 }, + }, + ]); }); test("header-max-length", async () => { const result = await commitLint(messages.invalidHeaderMaxLength); + const header = messages.invalidHeaderMaxLength; expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.headerMaxLength]); + expect(result.errors).toEqual([ + { + ...errors.headerMaxLength, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: header.length + 1, offset: header.length }, + }, + ]); }); test("footer-leading-blank", async () => { const result = await commitLint(messages.warningFooterLeadingBlank); + const message = messages.warningFooterLeadingBlank; + const footerOffset = message.indexOf("BREAKING CHANGE") - 1; expect(result.valid).toBe(true); - expect(result.warnings).toEqual([warnings.footerLeadingBlank]); + expect(result.warnings).toEqual([ + { + ...warnings.footerLeadingBlank, + start: { + line: 3, + column: message.split("\n")[2].length + 1, + offset: footerOffset, + }, + end: { + line: 3, + column: message.split("\n")[2].length + 1, + offset: footerOffset, + }, + }, + ]); }); test("footer-max-line-length", async () => { const result = await commitLint(messages.invalidFooterMaxLineLength); expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.footerMaxLineLength]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject(errors.footerMaxLineLength); + expect(result.errors[0].start).toBeDefined(); + expect(result.errors[0].end).toBeDefined(); }); test("body-leading-blank", async () => { const result = await commitLint(messages.warningBodyLeadingBlank); + const message = messages.warningBodyLeadingBlank; + const headerLength = message.split("\n")[0].length; expect(result.valid).toBe(true); - expect(result.warnings).toEqual([warnings.bodyLeadingBlank]); + expect(result.warnings).toEqual([ + { + ...warnings.bodyLeadingBlank, + start: { + line: 1, + column: headerLength + 1, + offset: headerLength, + }, + end: { + line: 1, + column: headerLength + 1, + offset: headerLength, + }, + }, + ]); }); test("body-max-line-length", async () => { const result = await commitLint(messages.invalidBodyMaxLineLength); expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.bodyMaxLineLength]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject(errors.bodyMaxLineLength); + expect(result.errors[0].start).toBeDefined(); + expect(result.errors[0].end).toBeDefined(); }); test("valid messages", async () => { diff --git a/@commitlint/format/src/format.test.ts b/@commitlint/format/src/format.test.ts index 3388e96afb..11b83d596b 100644 --- a/@commitlint/format/src/format.test.ts +++ b/@commitlint/format/src/format.test.ts @@ -54,7 +54,7 @@ test("returns empty summary with full commit message if verbose", () => { ); expect(actual).toStrictEqual( - "⧗ input: feat(cli): this is a valid header\n\nThis is a valid body\n\nSigned-off-by: tester\n✔ found 0 problems, 0 warnings", + "⧗ input: feat(cli): this is a valid header\n \n This is a valid body\n \n Signed-off-by: tester\n✔ found 0 problems, 0 warnings", ); }); @@ -303,3 +303,222 @@ test("format result should not contain `Get help` prefix if helpUrl is not provi expect.arrayContaining([expect.stringContaining("Get help:")]), ); }); + +test("shows position indicator when showPosition is true and error has position", () => { + const actual = format( + { + results: [ + { + errors: [ + { + level: 2, + name: "type-enum", + message: "type must be one of [feat, fix]", + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + ], + input: "foo: some message", + }, + ], + }, + { + showPosition: true, + color: false, + }, + ); + + expect(actual).toContain("^"); +}); + +test("does not show position indicator when showPosition is false", () => { + const actual = format( + { + results: [ + { + errors: [ + { + level: 2, + name: "type-enum", + message: "type must be one of [feat, fix]", + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + ], + input: "foo: some message", + }, + ], + }, + { + showPosition: false, + color: false, + }, + ); + + expect(actual).not.toContain("^"); +}); + +test("shows position indicator when showPosition is not provided (default)", () => { + const actual = format( + { + results: [ + { + errors: [ + { + level: 2, + name: "type-enum", + message: "type must be one of [feat, fix]", + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + ], + input: "foo: some message", + }, + ], + }, + { + color: false, + }, + ); + + expect(actual).toContain("^"); +}); + +test("does not show position indicator when error has no position", () => { + const actual = format( + { + results: [ + { + errors: [ + { + level: 2, + name: "type-enum", + message: "type must be one of [feat, fix]", + }, + ], + input: "foo: some message", + }, + ], + }, + { + showPosition: true, + color: false, + }, + ); + + expect(actual).not.toContain("^"); +}); + +test("shows correct position for subject error", () => { + const actual = format( + { + results: [ + { + errors: [ + { + level: 2, + name: "subject-max-length", + message: "subject must not be longer than 72 characters", + start: { line: 1, column: 10, offset: 9 }, + end: { line: 1, column: 50, offset: 49 }, + }, + ], + input: + "feat: this is a subject that is way too long for the commit message format", + }, + ], + }, + { + showPosition: true, + color: false, + }, + ); + + expect(actual).toContain("^"); +}); + +test("position indicator alignment is identical with or without color", () => { + const result = { + errors: [ + { + level: 2 as const, + name: "subject-max-length", + message: "subject must not be longer than 72 characters", + start: { line: 1, column: 10, offset: 9 }, + end: { line: 1, column: 50, offset: 49 }, + }, + ], + input: "feat: this subject is going to be a bit too long", + }; + + const colored = format({ results: [result] }, { showPosition: true }); + const plain = format( + { results: [result] }, + { showPosition: true, color: false }, + ); + + const indicatorOf = (output: string) => + output.split("\n").find((line) => line.trimStart().startsWith("^")); + + expect(indicatorOf(colored)).toBe(indicatorOf(plain)); +}); + +test("renders position indicator under the failing line for multi-line input", () => { + const actual = format( + { + results: [ + { + errors: [ + { + level: 2, + name: "body-max-line-length", + message: "body must not have lines longer than 80 characters", + start: { line: 3, column: 1, offset: 14 }, + end: { line: 3, column: 100, offset: 113 }, + }, + ], + input: "feat: header\n\nthis body line is far too long to fit", + }, + ], + }, + { + showPosition: true, + color: false, + }, + ); + + const lines = actual.split("\n"); + const bodyLineIndex = lines.findIndex((l) => + l.includes("this body line is far too long"), + ); + expect(bodyLineIndex).toBeGreaterThan(-1); + expect(lines[bodyLineIndex + 1]).toContain("^"); +}); + +test("shows position indicator with single caret for longer errors", () => { + const actual = format( + { + results: [ + { + errors: [ + { + level: 2, + name: "header-max-length", + message: "header must not be longer than 100 characters", + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 80, offset: 79 }, + }, + ], + input: + "feat: this is a very long header that exceeds the maximum allowed character limit for the commit message", + }, + ], + }, + { + showPosition: true, + color: false, + }, + ); + + expect(actual).toContain("^"); +}); diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index 2496d7272e..02f4c5dbf8 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -5,6 +5,7 @@ import { FormatOptions, FormattableResult, WithInput, + FormattableProblem, } from "@commitlint/types"; const DEFAULT_SIGNS = [" ", "⚠", "✖"] as const; @@ -37,7 +38,7 @@ function formatInput( result: FormattableResult & WithInput, options: FormatOptions = {}, ): string[] { - const { color: enabled = true } = options; + const { color: enabled = true, showPosition = true } = options; const { errors = [], warnings = [], input = "" } = result; if (!input) { @@ -46,13 +47,69 @@ function formatInput( const sign = "⧗"; const decoration = enabled ? pc.gray(sign) : sign; + const prefix = `${decoration} input: `; + const visiblePrefixLength = `${sign} input: `.length; + const padding = " ".repeat(visiblePrefixLength); - const decoratedInput = enabled ? pc.bold(input) : input; const hasProblems = errors.length > 0 || warnings.length > 0; + const normalizedInput = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const inputLines = normalizedInput.split("\n"); - return options.verbose || hasProblems - ? [`${decoration} input: ${decoratedInput}`] - : []; + const renderedInputLines = inputLines.map((lineText, i) => { + const decoratedLine = enabled ? pc.bold(lineText) : lineText; + const linePrefix = i === 0 ? prefix : padding; + return `${linePrefix}${decoratedLine}`; + }); + + if (!hasProblems) { + return options.verbose ? renderedInputLines : []; + } + + const indicator = showPosition + ? getPositionIndicator( + [...errors, ...warnings], + inputLines, + visiblePrefixLength, + ) + : undefined; + + if (!indicator) { + return renderedInputLines; + } + + const lines: string[] = []; + for (let i = 0; i < renderedInputLines.length; i++) { + lines.push(renderedInputLines[i]); + if (i + 1 === indicator.line) { + lines.push(indicator.text); + } + } + return lines; +} + +function getPositionIndicator( + problems: FormattableProblem[], + inputLines: string[], + prefixLength: number, +): { text: string; line: number } | undefined { + const problemWithPosition = problems.find( + (problem) => problem?.start !== undefined && problem?.end !== undefined, + ); + if (!problemWithPosition?.start || !problemWithPosition?.end) { + return undefined; + } + + const targetLine = inputLines[problemWithPosition.start.line - 1]; + if (targetLine === undefined) { + return undefined; + } + + const padding = " ".repeat(prefixLength); + const caret = "^"; + const spacesBefore = Math.max(0, problemWithPosition.start.column - 1); + const text = padding + " ".repeat(spacesBefore) + caret; + + return { text, line: problemWithPosition.start.line }; } export function formatResult( diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 0d37aca402..8c4525caec 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -169,7 +169,7 @@ test("throws for rule with out of range condition", async () => { }); test("succeds for issue", async () => { - const report = await lint("somehting #1", { + const report = await lint("something #1", { "references-empty": [RuleConfigSeverity.Error, "never"], }); @@ -177,7 +177,7 @@ test("succeds for issue", async () => { }); test("fails for issue", async () => { - const report = await lint("somehting #1", { + const report = await lint("something #1", { "references-empty": [RuleConfigSeverity.Error, "always"], }); @@ -186,7 +186,7 @@ test("fails for issue", async () => { test("succeds for custom issue prefix", async () => { const report = await lint( - "somehting REF-1", + "something REF-1", { "references-empty": [RuleConfigSeverity.Error, "never"], }, @@ -202,7 +202,7 @@ test("succeds for custom issue prefix", async () => { test("fails for custom issue prefix", async () => { const report = await lint( - "somehting #1", + "something #1", { "references-empty": [RuleConfigSeverity.Error, "never"], }, @@ -218,7 +218,7 @@ test("fails for custom issue prefix", async () => { test("fails for custom plugin rule", async () => { const report = await lint( - "somehting #1", + "something #1", { "plugin-rule": [RuleConfigSeverity.Error, "never"], }, @@ -238,7 +238,7 @@ test("fails for custom plugin rule", async () => { test("passes for custom plugin rule", async () => { const report = await lint( - "somehting #1", + "something #1", { "plugin-rule": [RuleConfigSeverity.Error, "never"], }, @@ -297,7 +297,7 @@ test("returns original message with commit header, body and footer, parsing comm test("passes for async rule", async () => { const report = await lint( - "somehting #1", + "something #1", { "async-rule": [RuleConfigSeverity.Error, "never"], }, @@ -314,3 +314,238 @@ test("passes for async rule", async () => { expect(report.valid).toBe(true); }); + +test("returns position for type-enum error", async () => { + const result = await lint("foo: some message", { + "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], + }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].name).toBe("type-enum"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ line: 1, column: 4, offset: 3 }); +}); + +test("returns position for type-case error", async () => { + const result = await lint("FIX: some message", { + "type-case": [RuleConfigSeverity.Error, "always", "lower-case"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("type-case"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ line: 1, column: 4, offset: 3 }); +}); + +test("returns position for type-max-length error", async () => { + const longType = "toolongtype"; + const result = await lint(`${longType}: some message`, { + "type-max-length": [RuleConfigSeverity.Error, "always", 5], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("type-max-length"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ + line: 1, + column: longType.length + 1, + offset: longType.length, + }); +}); + +test("returns position for scope-enum error", async () => { + const result = await lint("feat(badscope): some message", { + "scope-enum": [RuleConfigSeverity.Error, "always", ["cli", "core"]], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("scope-enum"); + expect(result.errors[0].start).toEqual({ line: 1, column: 6, offset: 5 }); + expect(result.errors[0].end).toEqual({ line: 1, column: 14, offset: 13 }); +}); + +test("returns position for scope-case error", async () => { + const result = await lint("feat(SCOPE): some message", { + "scope-case": [RuleConfigSeverity.Error, "always", "lower-case"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("scope-case"); + expect(result.errors[0].start).toEqual({ line: 1, column: 6, offset: 5 }); + expect(result.errors[0].end).toEqual({ line: 1, column: 11, offset: 10 }); +}); + +test("returns position for subject-max-length error", async () => { + const longSubject = + "this is a very long subject that exceeds the maximum allowed characters"; + const result = await lint(`feat: ${longSubject}`, { + "subject-max-length": [RuleConfigSeverity.Error, "always", 20], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-max-length"); + expect(result.errors[0].start?.line).toBe(1); + expect(result.errors[0].start?.column).toBeGreaterThan(5); + expect(result.errors[0].end?.line).toBe(1); +}); + +test("returns position for subject-full-stop error", async () => { + const result = await lint("feat: some message.", { + "subject-full-stop": [RuleConfigSeverity.Error, "never", "."], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-full-stop"); + expect(result.errors[0].start?.line).toBe(1); + expect(result.errors[0].start?.column).toBeGreaterThan(5); +}); + +test("returns position for header-max-length error", async () => { + const longHeader = + "feat: this is a very long header that definitely exceeds the maximum allowed character limit for commit messages"; + const result = await lint(longHeader, { + "header-max-length": [RuleConfigSeverity.Error, "always", 50], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("header-max-length"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ + line: 1, + column: longHeader.length + 1, + offset: longHeader.length, + }); +}); + +test("returns position for body-max-line-length error", async () => { + const longBodyLine = + "this is a body line that is way too long and exceeds the maximum allowed character limit of one hundred characters for each line in the body"; + const result = await lint(`feat: some message\n\n${longBodyLine}`, { + "body-max-line-length": [RuleConfigSeverity.Error, "always", 80], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-max-line-length"); + expect(result.errors[0].start?.line).toBe(3); +}); + +test("returns subject position with custom parserOpts.headerPattern", async () => { + // type-scope-subject grammar (non-default headerPattern). Subject + // position must be located by searching the header rather than + // computed from a hard-coded ": " separator. + const result = await lint( + "foo-bar", + { + "subject-min-length": [RuleConfigSeverity.Error, "always", 10], + }, + { + parserOpts: { + headerPattern: /^(\w*)(?:\((.*)\))?-(.*)$/, + }, + }, + ); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-min-length"); + // "foo-bar": "bar" starts at offset 4 + expect(result.errors[0].start?.column).toBe(5); +}); + +test("body-empty returns position for header-only commits", async () => { + const result = await lint("feat: head", { + "body-empty": [RuleConfigSeverity.Error, "never"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-empty"); + // "feat: head".length === 10 + expect(result.errors[0].start?.offset).toBe(10); +}); + +test("subject-exclamation-mark caret ignores bangs inside the subject", async () => { + // Header has "!" inside the subject text; the rule fires under "always" + // because there's no breaking-change "!" before the colon. The caret + // should land on the colon (where "!" should go), not on the bang in + // "hello!". + const result = await lint("feat: hello! world", { + "subject-exclamation-mark": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-exclamation-mark"); + expect(result.errors[0].start?.column).toBe(5); +}); + +test("returns body position for CRLF-style commit messages", async () => { + const longBodyLine = + "this is a body line that is way too long and exceeds the maximum allowed character limit of one hundred characters for each line in the body"; + const result = await lint(`feat: head\r\n\r\n${longBodyLine}`, { + "body-max-line-length": [RuleConfigSeverity.Error, "always", 80], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-max-line-length"); + expect(result.errors[0].start).toBeDefined(); +}); + +test("returns subject position even when type and subject share text", async () => { + const result = await lint("foo: foo", { + "subject-min-length": [RuleConfigSeverity.Error, "always", 10], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-min-length"); + expect(result.errors[0].start?.column).toBe(6); +}); + +test("returns correct footer line for multi-line body", async () => { + const longFooter = + "BREAKING CHANGE: a footer line that is far too long to fit within the configured maximum allowed character limit for the footer"; + const message = `feat: head\n\nbody line 1\nbody line 2\nbody line 3\n\n${longFooter}`; + const result = await lint(message, { + "footer-max-line-length": [RuleConfigSeverity.Error, "always", 80], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("footer-max-line-length"); + expect(result.errors[0].start?.line).toBe(7); +}); + +test("returns position for body-leading-blank pointing at end of header", async () => { + const result = await lint("feat: head\nbody content", { + "body-leading-blank": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-leading-blank"); + // Header "feat: head" is 10 chars; offset 10 is the line break after it. + expect(result.errors[0].start?.offset).toBe(10); +}); + +test("body-leading-blank position ignores paragraph breaks inside body", async () => { + // raw contains a "\n\n" inside the body, but the rule fires because + // the blank between header and body is missing. Caret must point at + // the header/body boundary, not the in-body paragraph break. + const result = await lint("feat: head\nfirst paragraph\n\nsecond paragraph", { + "body-leading-blank": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-leading-blank"); + expect(result.errors[0].start?.offset).toBe(10); +}); + +test("returns position for footer-leading-blank pointing right before footer", async () => { + const message = "feat: head\n\nbody\nBREAKING CHANGE: something"; + const result = await lint(message, { + "footer-leading-blank": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("footer-leading-blank"); + // Footer starts at index of "BREAKING CHANGE"; caret points at the + // character immediately before it. + const expected = message.indexOf("BREAKING CHANGE") - 1; + expect(result.errors[0].start?.offset).toBe(expected); +}); + +test("returns no position for rules without position support", async () => { + const result = await lint("something #1", { + "references-empty": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("references-empty"); + expect(result.errors[0].start).toBeUndefined(); + expect(result.errors[0].end).toBeUndefined(); +}); + +test("returns correct position for valid commit (no position needed)", async () => { + const result = await lint("feat: add new feature", { + "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); +}); diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index c64bb829e0..ddab206b16 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -10,11 +10,166 @@ import type { BaseRule, RuleType, QualifiedRules, + Position, } from "@commitlint/types"; import { RuleConfigSeverity } from "@commitlint/types"; import { buildCommitMessage } from "./commit-message.js"; +function offsetToPosition(raw: string, offset: number): Position { + const before = raw.slice(0, offset); + const newlineCount = (before.match(/\n/g) || []).length; + const lastNewline = before.lastIndexOf("\n"); + const column = lastNewline === -1 ? offset + 1 : offset - lastNewline; + return { line: newlineCount + 1, column, offset }; +} + +function span( + raw: string, + startOffset: number, + endOffset: number, +): { start: Position; end: Position } { + return { + start: offsetToPosition(raw, startOffset), + end: offsetToPosition(raw, Math.min(endOffset, raw.length)), + }; +} + +function point( + raw: string, + offset: number, +): { start: Position; end: Position } { + const p = offsetToPosition(raw, offset); + return { start: p, end: p }; +} + +type ParsedCommit = { + raw?: string; + header?: string | null; + type?: string | null; + subject?: string | null; + scope?: string | null; + body?: string | null; + footer?: string | null; +}; + +function ruleField( + ruleName: string, +): "type" | "scope" | "subject" | "header" | "body" | "footer" | undefined { + if (ruleName.startsWith("type-")) return "type"; + if (ruleName.startsWith("scope-")) return "scope"; + if (ruleName.startsWith("subject-")) return "subject"; + if (ruleName.startsWith("header-")) return "header"; + if (ruleName.startsWith("body-")) return "body"; + if (ruleName.startsWith("footer-")) return "footer"; + return undefined; +} + +function fieldSpan( + field: NonNullable>, + ruleName: string, + parsed: ParsedCommit, + raw: string, + header: string, +): { start: Position; end: Position } | undefined { + switch (field) { + case "type": { + if (!parsed.type) { + return ruleName === "type-empty" ? point(raw, 0) : undefined; + } + const offset = header.indexOf(parsed.type); + if (offset === -1) return undefined; + return span(raw, offset, offset + parsed.type.length); + } + case "scope": { + if (!parsed.scope) { + if (ruleName === "scope-empty") { + const typeEnd = parsed.type ? parsed.type.length : 0; + return point(raw, typeEnd + 1); + } + return undefined; + } + const parenStart = header.indexOf(`(${parsed.scope})`); + const offset = + parenStart >= 0 ? parenStart + 1 : header.lastIndexOf(parsed.scope); + if (offset === -1) return undefined; + return span(raw, offset, offset + parsed.scope.length); + } + case "subject": { + if (!parsed.subject) { + return ruleName === "subject-empty" + ? point(raw, header.length) + : undefined; + } + const offset = header.lastIndexOf(parsed.subject); + if (offset === -1) return undefined; + return span(raw, offset, offset + parsed.subject.length); + } + case "header": { + if (!header) return undefined; + return span(raw, 0, header.length); + } + case "body": { + const blank = raw.indexOf("\n\n"); + if (!parsed.body) { + if (ruleName === "body-empty") { + return point(raw, blank === -1 ? header.length : blank + 2); + } + return undefined; + } + if (blank === -1) return undefined; + const start = blank + 2; + return span(raw, start, start + parsed.body.length); + } + case "footer": { + const blank = raw.lastIndexOf("\n\n"); + if (!parsed.footer) { + if (ruleName === "footer-empty" && blank !== -1) { + return point(raw, blank + 2); + } + return undefined; + } + if (blank === -1) return undefined; + const start = blank + 2; + return span(raw, start, start + parsed.footer.length); + } + } +} + +function getRulePosition( + ruleName: string, + parsed: ParsedCommit, +): { start: Position; end: Position } | undefined { + const raw = (parsed.raw || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!raw) return undefined; + + const header = parsed.header || ""; + + // Boundary special cases — exact-character precision matters. + if (ruleName === "subject-exclamation-mark") { + const colonIndex = header.indexOf(":"); + if (colonIndex === -1) return undefined; + const bangIndex = + colonIndex > 0 && header[colonIndex - 1] === "!" + ? colonIndex - 1 + : colonIndex; + return point(raw, bangIndex); + } + if (ruleName === "body-leading-blank") { + return parsed.body ? point(raw, header.length) : undefined; + } + if (ruleName === "footer-leading-blank") { + if (!parsed.footer) return undefined; + const footerStart = raw.indexOf(parsed.footer); + if (footerStart <= 0) return undefined; + return point(raw, footerStart - 1); + } + + const field = ruleField(ruleName); + if (!field) return undefined; + return fieldSpan(field, ruleName, parsed, raw, header); +} + export default async function lint( message: string, rawRulesConfig?: QualifiedRules, @@ -169,12 +324,18 @@ export default async function lint( const executableRule = rule as Rule; const [valid, message] = await executableRule(parsed, when, value); - return { + const position = !valid ? getRulePosition(name, parsed) : undefined; + + const outcome: LintRuleOutcome = { level, valid, name, - message, + message: message ?? "", + start: position?.start, + end: position?.end, }; + + return outcome; }); const results = (await Promise.all(pendingResults)).filter( diff --git a/@commitlint/types/src/format.ts b/@commitlint/types/src/format.ts index ca9f87495a..21edc1d969 100644 --- a/@commitlint/types/src/format.ts +++ b/@commitlint/types/src/format.ts @@ -7,10 +7,21 @@ export type Formatter = ( options: FormatOptions, ) => string; +export interface Position { + /** 1-indexed line in the input. */ + line: number; + /** 1-indexed character column on the line (not display width). */ + column: number; + /** 0-indexed character offset from the start of the input. */ + offset: number; +} + export interface FormattableProblem { level: RuleConfigSeverity; name: keyof QualifiedRules; message: string; + start?: Position; + end?: Position; } export interface FormattableResult { @@ -41,4 +52,5 @@ export interface FormatOptions { colors?: readonly [PicocolorsColor, PicocolorsColor, PicocolorsColor]; verbose?: boolean; helpUrl?: string; + showPosition?: boolean; } diff --git a/@commitlint/types/src/lint.ts b/@commitlint/types/src/lint.ts index 7640efc362..c8bf08b81b 100644 --- a/@commitlint/types/src/lint.ts +++ b/@commitlint/types/src/lint.ts @@ -1,4 +1,5 @@ import type { ParserOptions as Options } from "conventional-commits-parser"; +import { Position } from "./format.js"; import { IsIgnoredOptions } from "./is-ignored.js"; import { PluginRecords } from "./load.js"; import { RuleConfigSeverity, RuleConfigTuple } from "./rules.js"; @@ -42,4 +43,8 @@ export interface LintRuleOutcome { name: string; /** The message returned from the rule, if invalid */ message: string; + /** The start position of the error in the input */ + start?: Position; + /** The end position of the error in the input */ + end?: Position; } diff --git a/docs/api/format.md b/docs/api/format.md index 55a8ead843..c003b76d1e 100644 --- a/docs/api/format.md +++ b/docs/api/format.md @@ -24,6 +24,17 @@ type Problem = { * Message to print */ message: string; + /* + * Start position of the problem in the input. + * Required (together with `end`) for the position + * indicator (`^`) to render under the input line + * when `showPosition` is enabled. + */ + start?: { line: number; column: number; offset: number }; + /* + * End position of the problem in the input. + */ + end?: { line: number; column: number; offset: number }; } type Report = { @@ -60,6 +71,12 @@ type formatOptions = { * URL to print as help for reports with problems **/ helpUrl: string; + + /** + * Show position indicator (^) for errors in the input line. + * Defaults to `true`. + **/ + showPosition?: boolean; } format(report?: Report = {}, options?: formatOptions = {}) => string[]; diff --git a/docs/reference/cli.md b/docs/reference/cli.md index efc0656a62..be70d36594 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -42,6 +42,7 @@ Options: edit=false [string] -V, --verbose enable verbose output for reports without problems [boolean] + --show-position show position of error in output [boolean] [default: true] -s, --strict enable strict mode; result code 2 for warnings, 3 for errors [boolean] --options path to a JSON file or Common.js module containing CLI