Skip to content
Open
1 change: 1 addition & 0 deletions @commitlint/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,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]
Expand Down
6 changes: 6 additions & 0 deletions @commitlint/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,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",
Expand Down Expand Up @@ -374,6 +379,7 @@ async function main(args: MainArgs): Promise<void> {
color: flags.color,
verbose: flags.verbose,
helpUrl,
showPosition: flags["show-position"],
});

if (!flags.quiet && output !== "") {
Expand Down
1 change: 1 addition & 0 deletions @commitlint/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface CliFlags {
to?: string;
version?: boolean;
verbose?: boolean;
"show-position"?: boolean;
/** @type {'' | 'text' | 'json'} */
"print-config"?: string;
strict?: boolean;
Expand Down
22 changes: 11 additions & 11 deletions @commitlint/config-conventional/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,21 @@ test("type-enum", async () => {
const result = await commitLint(messages.invalidTypeEnum);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.typeEnum]);
expect(result.errors).toMatchObject([errors.typeEnum]);
});

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).toMatchObject([errors.typeCase, errors.typeEnum]);
});

test("type-empty", async () => {
const result = await commitLint(messages.invalidTypeEmpty);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.typeEmpty]);
expect(result.errors).toMatchObject([errors.typeEmpty]);
});

test("subject-case", async () => {
Expand All @@ -158,57 +158,57 @@ test("subject-case", async () => {

invalidInputs.forEach((result) => {
expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.subjectCase]);
expect(result.errors).toMatchObject([errors.subjectCase]);
});
});

test("subject-empty", async () => {
const result = await commitLint(messages.invalidSubjectEmpty);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.subjectEmpty, errors.typeEmpty]);
expect(result.errors).toMatchObject([errors.subjectEmpty, errors.typeEmpty]);
});

test("subject-full-stop", async () => {
const result = await commitLint(messages.invalidSubjectFullStop);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.subjectFullStop]);
expect(result.errors).toMatchObject([errors.subjectFullStop]);
});

test("header-max-length", async () => {
const result = await commitLint(messages.invalidHeaderMaxLength);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.headerMaxLength]);
expect(result.errors).toMatchObject([errors.headerMaxLength]);
});

test("footer-leading-blank", async () => {
const result = await commitLint(messages.warningFooterLeadingBlank);

expect(result.valid).toBe(true);
expect(result.warnings).toEqual([warnings.footerLeadingBlank]);
expect(result.warnings).toMatchObject([warnings.footerLeadingBlank]);
});

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).toMatchObject([errors.footerMaxLineLength]);
});

test("body-leading-blank", async () => {
const result = await commitLint(messages.warningBodyLeadingBlank);

expect(result.valid).toBe(true);
expect(result.warnings).toEqual([warnings.bodyLeadingBlank]);
expect(result.warnings).toMatchObject([warnings.bodyLeadingBlank]);
});

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).toMatchObject([errors.bodyMaxLineLength]);
});

test("valid messages", async () => {
Expand Down
161 changes: 161 additions & 0 deletions @commitlint/format/src/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,164 @@ 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("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("^");
});
53 changes: 49 additions & 4 deletions @commitlint/format/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FormatOptions,
FormattableResult,
WithInput,
FormattableProblem,
} from "@commitlint/types";

const DEFAULT_SIGNS = [" ", "⚠", "✖"] as const;
Expand Down Expand Up @@ -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) {
Expand All @@ -46,13 +47,57 @@ function formatInput(

const sign = "⧗";
const decoration = enabled ? pc.gray(sign) : sign;
const prefix = `${decoration} input: `;

const decoratedInput = enabled ? pc.bold(input) : input;
const hasProblems = errors.length > 0 || warnings.length > 0;

return options.verbose || hasProblems
? [`${decoration} input: ${decoratedInput}`]
: [];
if (!hasProblems) {
return options.verbose ? [`${prefix}${decoratedInput}`] : [];
}

const positionIndicator = showPosition
? getPositionIndicator([...errors, ...warnings], input, prefix.length)
: undefined;

const lines: string[] = [`${prefix}${decoratedInput}`];

if (positionIndicator) {
lines.push(positionIndicator);
}

return lines;
}

function getPositionIndicator(
problems: FormattableProblem[],
input: string,
prefixLength: number,
): string | undefined {
const problemWithPosition = problems.find(
(problem) => problem?.start !== undefined && problem?.end !== undefined,
);
if (!problemWithPosition?.start || !problemWithPosition?.end) {
return undefined;
}

const padding = " ".repeat(prefixLength);

const caret = "^";

const normalizedInput = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = normalizedInput.split("\n");
const targetLine = lines[problemWithPosition.start.line - 1];

if (!targetLine) {
return undefined;
}

const spacesBefore = Math.max(0, problemWithPosition.start.column - 1);

const indicator = padding + " ".repeat(spacesBefore) + caret;

return indicator;
}

export function formatResult(
Expand Down
Loading