Skip to content

Commit 3ea0fce

Browse files
committed
feat(cli): add --show-position flag to display error location
Adds a new --show-position CLI option that displays a position indicator (~~~) under the commit input to show exactly where the error occurs, similar to TypeScript's red squiggly lines. This helps users quickly identify the problematic part of their commit message. Features: - Add optional start/end position fields to LintRuleOutcome and FormattableProblem - Add getRulePosition() helper to calculate error positions for various rules - Add showPosition option to FormatOptions - Add --show-position CLI flag (opt-in, default false) - Add tests for position indicator in format package - Update config-conventional tests to use toMatchObject for backward compatibility
1 parent 81cfc9e commit 3ea0fce

10 files changed

Lines changed: 406 additions & 18 deletions

File tree

@commitlint/cli/src/cli.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,7 @@ test("should print help", async () => {
606606
-q, --quiet toggle console output [boolean] [default: false]
607607
-t, --to upper end of the commit range to lint; applies if edit=false [string]
608608
-V, --verbose enable verbose output for reports without problems [boolean]
609+
--show-position show position of error in output [boolean]
609610
-s, --strict enable strict mode; result code 2 for warnings, 3 for errors [boolean]
610611
--options path to a JSON file or Common.js module containing CLI options
611612
-v, --version display version information [boolean]

@commitlint/cli/src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ const cli = yargs(process.argv.slice(2))
134134
type: "boolean",
135135
description: "enable verbose output for reports without problems",
136136
},
137+
"show-position": {
138+
type: "boolean",
139+
description: "show position of error in output",
140+
},
137141
strict: {
138142
alias: "s",
139143
type: "boolean",
@@ -374,6 +378,7 @@ async function main(args: MainArgs): Promise<void> {
374378
color: flags.color,
375379
verbose: flags.verbose,
376380
helpUrl,
381+
showPosition: flags["show-position"],
377382
});
378383

379384
if (!flags.quiet && output !== "") {

@commitlint/cli/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface CliFlags {
1717
to?: string;
1818
version?: boolean;
1919
verbose?: boolean;
20+
"show-position"?: boolean;
2021
/** @type {'' | 'text' | 'json'} */
2122
"print-config"?: string;
2223
strict?: boolean;

@commitlint/config-conventional/src/index.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,21 +132,21 @@ test("type-enum", async () => {
132132
const result = await commitLint(messages.invalidTypeEnum);
133133

134134
expect(result.valid).toBe(false);
135-
expect(result.errors).toEqual([errors.typeEnum]);
135+
expect(result.errors).toMatchObject([errors.typeEnum]);
136136
});
137137

138138
test("type-case", async () => {
139139
const result = await commitLint(messages.invalidTypeCase);
140140

141141
expect(result.valid).toBe(false);
142-
expect(result.errors).toEqual([errors.typeCase, errors.typeEnum]);
142+
expect(result.errors).toMatchObject([errors.typeCase, errors.typeEnum]);
143143
});
144144

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

148148
expect(result.valid).toBe(false);
149-
expect(result.errors).toEqual([errors.typeEmpty]);
149+
expect(result.errors).toMatchObject([errors.typeEmpty]);
150150
});
151151

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

159159
invalidInputs.forEach((result) => {
160160
expect(result.valid).toBe(false);
161-
expect(result.errors).toEqual([errors.subjectCase]);
161+
expect(result.errors).toMatchObject([errors.subjectCase]);
162162
});
163163
});
164164

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

168168
expect(result.valid).toBe(false);
169-
expect(result.errors).toEqual([errors.subjectEmpty, errors.typeEmpty]);
169+
expect(result.errors).toMatchObject([errors.subjectEmpty, errors.typeEmpty]);
170170
});
171171

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

175175
expect(result.valid).toBe(false);
176-
expect(result.errors).toEqual([errors.subjectFullStop]);
176+
expect(result.errors).toMatchObject([errors.subjectFullStop]);
177177
});
178178

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

182182
expect(result.valid).toBe(false);
183-
expect(result.errors).toEqual([errors.headerMaxLength]);
183+
expect(result.errors).toMatchObject([errors.headerMaxLength]);
184184
});
185185

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

189189
expect(result.valid).toBe(true);
190-
expect(result.warnings).toEqual([warnings.footerLeadingBlank]);
190+
expect(result.warnings).toMatchObject([warnings.footerLeadingBlank]);
191191
});
192192

193193
test("footer-max-line-length", async () => {
194194
const result = await commitLint(messages.invalidFooterMaxLineLength);
195195

196196
expect(result.valid).toBe(false);
197-
expect(result.errors).toEqual([errors.footerMaxLineLength]);
197+
expect(result.errors).toMatchObject([errors.footerMaxLineLength]);
198198
});
199199

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

203203
expect(result.valid).toBe(true);
204-
expect(result.warnings).toEqual([warnings.bodyLeadingBlank]);
204+
expect(result.warnings).toMatchObject([warnings.bodyLeadingBlank]);
205205
});
206206

207207
test("body-max-line-length", async () => {
208208
const result = await commitLint(messages.invalidBodyMaxLineLength);
209209

210210
expect(result.valid).toBe(false);
211-
expect(result.errors).toEqual([errors.bodyMaxLineLength]);
211+
expect(result.errors).toMatchObject([errors.bodyMaxLineLength]);
212212
});
213213

214214
test("valid messages", async () => {

@commitlint/format/src/format.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,166 @@ test("format result should not contain `Get help` prefix if helpUrl is not provi
303303
expect.arrayContaining([expect.stringContaining("Get help:")]),
304304
);
305305
});
306+
307+
test("shows position indicator when showPosition is true and error has position", () => {
308+
const actual = format(
309+
{
310+
results: [
311+
{
312+
errors: [
313+
{
314+
level: 2,
315+
name: "type-enum",
316+
message: "type must be one of [feat, fix]",
317+
start: { line: 1, column: 1, offset: 0 },
318+
end: { line: 1, column: 4, offset: 3 },
319+
},
320+
],
321+
input: "foo: some message",
322+
},
323+
],
324+
},
325+
{
326+
showPosition: true,
327+
color: false,
328+
},
329+
);
330+
331+
expect(actual).toContain("~~~");
332+
});
333+
334+
test("does not show position indicator when showPosition is false", () => {
335+
const actual = format(
336+
{
337+
results: [
338+
{
339+
errors: [
340+
{
341+
level: 2,
342+
name: "type-enum",
343+
message: "type must be one of [feat, fix]",
344+
start: { line: 1, column: 1, offset: 0 },
345+
end: { line: 1, column: 4, offset: 3 },
346+
},
347+
],
348+
input: "foo: some message",
349+
},
350+
],
351+
},
352+
{
353+
showPosition: false,
354+
color: false,
355+
},
356+
);
357+
358+
expect(actual).not.toContain("~~~");
359+
});
360+
361+
test("does not show position indicator when showPosition is not provided", () => {
362+
const actual = format(
363+
{
364+
results: [
365+
{
366+
errors: [
367+
{
368+
level: 2,
369+
name: "type-enum",
370+
message: "type must be one of [feat, fix]",
371+
start: { line: 1, column: 1, offset: 0 },
372+
end: { line: 1, column: 4, offset: 3 },
373+
},
374+
],
375+
input: "foo: some message",
376+
},
377+
],
378+
},
379+
{
380+
color: false,
381+
},
382+
);
383+
384+
expect(actual).not.toContain("~~~");
385+
});
386+
387+
test("does not show position indicator when error has no position", () => {
388+
const actual = format(
389+
{
390+
results: [
391+
{
392+
errors: [
393+
{
394+
level: 2,
395+
name: "type-enum",
396+
message: "type must be one of [feat, fix]",
397+
},
398+
],
399+
input: "foo: some message",
400+
},
401+
],
402+
},
403+
{
404+
showPosition: true,
405+
color: false,
406+
},
407+
);
408+
409+
expect(actual).not.toContain("~~~");
410+
});
411+
412+
test("shows correct position for subject error", () => {
413+
const actual = format(
414+
{
415+
results: [
416+
{
417+
errors: [
418+
{
419+
level: 2,
420+
name: "subject-max-length",
421+
message: "subject must not be longer than 72 characters",
422+
start: { line: 1, column: 10, offset: 9 },
423+
end: { line: 1, column: 50, offset: 49 },
424+
},
425+
],
426+
input:
427+
"feat: this is a subject that is way too long for the commit message format",
428+
},
429+
],
430+
},
431+
{
432+
showPosition: true,
433+
color: false,
434+
},
435+
);
436+
437+
expect(actual).toContain("~~~~~~~~");
438+
});
439+
440+
test("shows position indicator with multiple tildes for longer errors", () => {
441+
const actual = format(
442+
{
443+
results: [
444+
{
445+
errors: [
446+
{
447+
level: 2,
448+
name: "header-max-length",
449+
message: "header must not be longer than 100 characters",
450+
start: { line: 1, column: 1, offset: 0 },
451+
end: { line: 1, column: 80, offset: 79 },
452+
},
453+
],
454+
input:
455+
"feat: this is a very long header that exceeds the maximum allowed character limit for the commit message",
456+
},
457+
],
458+
},
459+
{
460+
showPosition: true,
461+
color: false,
462+
},
463+
);
464+
465+
expect(actual).toContain(
466+
"~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~",
467+
);
468+
});

@commitlint/format/src/format.ts

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
FormatOptions,
66
FormattableResult,
77
WithInput,
8+
FormattableProblem,
89
} from "@commitlint/types";
910

1011
const DEFAULT_SIGNS = [" ", "⚠", "✖"] as const;
@@ -37,7 +38,7 @@ function formatInput(
3738
result: FormattableResult & WithInput,
3839
options: FormatOptions = {},
3940
): string[] {
40-
const { color: enabled = true } = options;
41+
const { color: enabled = true, showPosition = false } = options;
4142
const { errors = [], warnings = [], input = "" } = result;
4243

4344
if (!input) {
@@ -50,9 +51,76 @@ function formatInput(
5051
const decoratedInput = enabled ? pc.bold(input) : input;
5152
const hasProblems = errors.length > 0 || warnings.length > 0;
5253

53-
return options.verbose || hasProblems
54-
? [`${decoration} input: ${decoratedInput}`]
55-
: [];
54+
if (!hasProblems) {
55+
return options.verbose ? [`${decoration} input: ${decoratedInput}`] : [];
56+
}
57+
58+
const positionIndicator = showPosition
59+
? getPositionIndicator(errors, input)
60+
: undefined;
61+
62+
const lines: string[] = [`${decoration} input: ${decoratedInput}`];
63+
64+
if (positionIndicator) {
65+
lines.push(positionIndicator);
66+
}
67+
68+
return lines;
69+
}
70+
71+
function getPositionIndicator(
72+
problems: FormattableProblem[],
73+
input: string,
74+
): string | undefined {
75+
const firstError = problems[0];
76+
if (!firstError?.start || !firstError?.end) {
77+
return undefined;
78+
}
79+
80+
const { start, end } = firstError;
81+
const padding = " ";
82+
83+
const tilde = "~";
84+
let indicator = "";
85+
86+
if (start.line === 1) {
87+
const spacesBefore = Math.max(0, start.column - 1);
88+
const tildeLength = Math.max(1, end.column - start.column);
89+
indicator = padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength);
90+
} else if (start.line === 2) {
91+
const headerEndIndex = input.indexOf("\n\n");
92+
if (headerEndIndex === -1) return undefined;
93+
94+
const bodyLineStart = headerEndIndex + 2;
95+
const charsOnLine = input.slice(bodyLineStart).indexOf("\n");
96+
const lineLength =
97+
charsOnLine === -1 ? input.length - bodyLineStart : charsOnLine;
98+
99+
if (start.column <= lineLength) {
100+
const spacesBefore = Math.max(0, start.column - 1);
101+
const tildeLength = Math.max(
102+
1,
103+
Math.min(end.column, lineLength) - start.column,
104+
);
105+
indicator =
106+
padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength);
107+
}
108+
} else if (start.line === 3) {
109+
const footerStartIndex = input.lastIndexOf("\n\n");
110+
if (footerStartIndex === -1) return undefined;
111+
112+
const footerLineStart = footerStartIndex + 2;
113+
const lineLength = input.length - footerLineStart;
114+
115+
if (start.column <= lineLength) {
116+
const spacesBefore = Math.max(0, start.column - 1);
117+
const tildeLength = Math.max(1, end.column - start.column);
118+
indicator =
119+
padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength);
120+
}
121+
}
122+
123+
return indicator || undefined;
56124
}
57125

58126
export function formatResult(

0 commit comments

Comments
 (0)