diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7c3f106 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## โœ๏ธ Describe your changes + +_None_ + +## ๐Ÿ”— Issue ticket number and link + +_None_ + +## โœ… Checklist before requesting a review + +- [ ] I have performed a self-review of my code +- [ ] If it is a core feature, I have added thorough tests +- [ ] I have checked that affected pages are responsive +- [ ] I have checked that there are no z-index issues on affected pages diff --git a/README.md b/README.md index a89f237..65422df 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Or lint a message from standard input: echo "feat(parser): add fast validation" | quick-commitlint ``` -Valid messages produce no output and exit with status `0`. Rule errors exit with status `1`; command, file, UTF-8, and configuration errors exit with status `2`. Warnings are printed but do not fail the hook. +Every lint result includes the elapsed lint time in milliseconds. Valid messages and warning-only results exit with status `0`; rule errors exit with status `1`; command, file, UTF-8, and configuration errors exit with status `2`. ## โš™๏ธ Configuration diff --git a/docs/assets/terminal-demo.gif b/docs/assets/terminal-demo.gif index a995256..fc752b0 100644 Binary files a/docs/assets/terminal-demo.gif and b/docs/assets/terminal-demo.gif differ diff --git a/docs/terminal-demo.tape b/docs/terminal-demo.tape index 2630818..d78fc63 100644 --- a/docs/terminal-demo.tape +++ b/docs/terminal-demo.tape @@ -41,7 +41,7 @@ Sleep 500ms Type "cat COMMIT_EDITMSG" Enter Sleep 750ms -Type "quick-commitlint COMMIT_EDITMSG && echo valid-commit" +Type "quick-commitlint COMMIT_EDITMSG" Enter Sleep 3s diff --git a/scripts/differential.ts b/scripts/differential.ts index f95cc3c..0f3e604 100644 --- a/scripts/differential.ts +++ b/scripts/differential.ts @@ -6,53 +6,354 @@ import { join, resolve } from 'path'; const native = resolve(__dirname, '..', 'zig-out', 'bin', 'quick-commitlint'); const commitlint = resolve(__dirname, '..', 'node_modules', '@commitlint', 'cli', 'cli.js'); -const conventionalCorpus = [ - ['feat: add parser', true], - ['fix(core): handle empty input', true], - ['docs: update readme', true], - ['chore: publish package', true], - ['feat!: change public API', true], - ['wat: unknown type', false], - ['FEAT: uppercase type', false], - ['fix:', false], - ['fix: Add capitalized subject', false], - ['fix: finish with a period.', false], - ['feat: add parser\nbody without blank', true] -] as const; +type CompatibilityCase = { + name: string; + message: string; + expectedPass: boolean; + expectedRule?: string; + unexpectedRule?: string; +}; -const angularCorpus = [ - ['feat: add parser', true], - ['fix(core): handle empty input', true], - ['chore: publish package', false], - ['feat!: change public API', false], - ['fix(Core): handle empty input', false], - ['fix: Add capitalized subject', false] -] as const; +const conventionalTypes = [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test' +]; +const angularTypes = ['build', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test']; + +const conventionalCorpus: CompatibilityCase[] = [ + ...conventionalTypes.map((type) => ({ + name: `allows ${type} type`, + message: `${type}: some message`, + expectedPass: true + })), + { + name: 'rejects unknown type', + message: 'foo: some message', + expectedPass: false, + expectedRule: 'type-enum' + }, + { + name: 'rejects uppercase type', + message: 'FIX: some message', + expectedPass: false, + expectedRule: 'type-case' + }, + { + name: 'rejects empty type', + message: ': some message', + expectedPass: false, + expectedRule: 'type-empty' + }, + { + name: 'allows uppercase scope', + message: 'fix(SCOPE): some message', + expectedPass: true + }, + { + name: 'rejects sentence-case subject', + message: 'fix(scope): Some message', + expectedPass: false, + expectedRule: 'subject-case' + }, + { + name: 'rejects start-case subject', + message: 'fix(scope): Some Message', + expectedPass: false, + expectedRule: 'subject-case' + }, + { + name: 'rejects pascal-case subject', + message: 'fix(scope): SomeMessage', + expectedPass: false, + expectedRule: 'subject-case' + }, + { + name: 'rejects upper-case subject', + message: 'fix(scope): SOMEMESSAGE', + expectedPass: false, + expectedRule: 'subject-case' + }, + { + name: 'allows lower-case subject', + message: 'fix(scope): some message', + expectedPass: true + }, + { + name: 'allows mixed-case subject', + message: 'fix(scope): some Message', + expectedPass: true + }, + { + name: 'rejects empty subject', + message: 'fix:', + expectedPass: false, + expectedRule: 'subject-empty' + }, + { + name: 'rejects subject full stop', + message: 'fix: some message.', + expectedPass: false, + expectedRule: 'subject-full-stop' + }, + { + name: 'allows breaking-change exclamation mark', + message: 'feat!: change public API', + expectedPass: true + }, + { + name: 'allows 100-character header', + message: `fix: ${'a'.repeat(95)}`, + expectedPass: true + }, + { + name: 'rejects 101-character header', + message: `fix: ${'a'.repeat(96)}`, + expectedPass: false, + expectedRule: 'header-max-length' + }, + { + name: 'rejects leading header whitespace', + message: ' fix: some message', + expectedPass: false, + expectedRule: 'header-trim' + }, + { + name: 'rejects trailing header whitespace', + message: 'fix: some message ', + expectedPass: false, + expectedRule: 'header-trim' + }, + { + name: 'warns when body has no leading blank', + message: 'feat: some message\nbody', + expectedPass: true, + expectedRule: 'body-leading-blank' + }, + { + name: 'allows body with leading blank', + message: 'feat: some message\n\nbody', + expectedPass: true, + unexpectedRule: 'body-leading-blank' + }, + { + name: 'allows 100-character body line', + message: `feat: some message\n\n${'a'.repeat(100)}`, + expectedPass: true + }, + { + name: 'rejects 101-character body line', + message: `feat: some message\n\n${'a'.repeat(101)}`, + expectedPass: false, + expectedRule: 'body-max-line-length' + }, + { + name: 'warns when footer has no leading blank', + message: 'feat: some message\nRefs: #1', + expectedPass: true, + expectedRule: 'footer-leading-blank' + }, + { + name: 'allows footer with leading blank', + message: 'feat: some message\n\nRefs: #1', + expectedPass: true, + unexpectedRule: 'footer-leading-blank' + }, + { + name: 'allows 100-character footer line', + message: `feat: some message\n\nBREAKING CHANGE: ${'a'.repeat(83)}`, + expectedPass: true + }, + { + name: 'rejects 101-character footer line', + message: `feat: some message\n\nBREAKING CHANGE: ${'a'.repeat(84)}`, + expectedPass: false, + expectedRule: 'footer-max-line-length' + } +]; + +const angularCorpus: CompatibilityCase[] = [ + ...angularTypes.map((type) => ({ + name: `allows ${type} type`, + message: `${type}: some message`, + expectedPass: true + })), + { + name: 'rejects unknown type', + message: 'foo: some message', + expectedPass: false, + expectedRule: 'type-enum' + }, + { + name: 'rejects uppercase type', + message: 'FIX: some message', + expectedPass: false, + expectedRule: 'type-case' + }, + { + name: 'rejects empty type', + message: ': some message', + expectedPass: false, + expectedRule: 'type-empty' + }, + { + name: 'allows lowercase scope', + message: 'fix(scope): some message', + expectedPass: true + }, + { + name: 'rejects uppercase scope', + message: 'fix(SCOPE): some message', + expectedPass: false, + expectedRule: 'scope-case' + }, + { + name: 'rejects sentence-case subject', + message: 'fix(scope): Some message', + expectedPass: false, + expectedRule: 'subject-case' + }, + { + name: 'rejects start-case subject', + message: 'fix(scope): Some Message', + expectedPass: false, + expectedRule: 'subject-case' + }, + { + name: 'rejects pascal-case subject', + message: 'fix(scope): SomeMessage', + expectedPass: false, + expectedRule: 'subject-case' + }, + { + name: 'rejects upper-case subject', + message: 'fix(scope): SOMEMESSAGE', + expectedPass: false, + expectedRule: 'subject-case' + }, + { + name: 'allows lower-case subject', + message: 'fix(scope): some message', + expectedPass: true + }, + { + name: 'allows mixed-case subject', + message: 'fix(scope): some Message', + expectedPass: true + }, + { + name: 'rejects empty subject', + message: 'fix:', + expectedPass: false, + expectedRule: 'subject-empty' + }, + { + name: 'rejects subject full stop', + message: 'fix: some message.', + expectedPass: false, + expectedRule: 'subject-full-stop' + }, + { + name: 'rejects breaking-change exclamation mark', + message: 'feat!: change public API', + expectedPass: false, + expectedRule: 'subject-exclamation-mark' + }, + { + name: 'allows 72-character header', + message: `fix: ${'a'.repeat(67)}`, + expectedPass: true + }, + { + name: 'rejects 73-character header', + message: `fix: ${'a'.repeat(68)}`, + expectedPass: false, + expectedRule: 'header-max-length' + }, + { + name: 'warns when body has no leading blank', + message: 'feat: some message\nbody', + expectedPass: true, + expectedRule: 'body-leading-blank' + }, + { + name: 'allows body with leading blank', + message: 'feat: some message\n\nbody', + expectedPass: true, + unexpectedRule: 'body-leading-blank' + }, + { + name: 'warns when footer has no leading blank', + message: 'feat: some message\nRefs: #1', + expectedPass: true, + expectedRule: 'footer-leading-blank' + }, + { + name: 'allows footer with leading blank', + message: 'feat: some message\n\nRefs: #1', + expectedPass: true, + unexpectedRule: 'footer-leading-blank' + } +]; const temp = mkdtempSync(join(tmpdir(), 'quick-commitlint-differential-')); const angularConfig = join(temp, 'angular.json'); writeFileSync(angularConfig, '{"preset":"angular"}\n'); function compare( - corpus: ReadonlyArray, + corpus: ReadonlyArray, nativeArgs: string[], preset: string ): void { - for (const [message, expected] of corpus) { - const nativeResult = spawnSync(native, nativeArgs, { input: message, encoding: 'utf8' }); + for (const testCase of corpus) { + const nativeResult = spawnSync(native, nativeArgs, { input: testCase.message, encoding: 'utf8' }); const nodeResult = spawnSync(process.execPath, [commitlint, '--extends', preset], { - input: message, + input: testCase.message, encoding: 'utf8', cwd: resolve(__dirname, '..') }); const nativePass = nativeResult.status === 0; const nodePass = nodeResult.status === 0; - if (nativePass !== expected || nodePass !== expected || nativePass !== nodePass) { + if ( + nativePass !== testCase.expectedPass || + nodePass !== testCase.expectedPass || + nativePass !== nodePass + ) { throw new Error( - `Differential mismatch for ${JSON.stringify(message)} using ${preset}: ` + - `quick-commitlint=${nativeResult.status}, commitlint=${nodeResult.status}, expected=${expected}` + `Differential mismatch for ${testCase.name} (${JSON.stringify(testCase.message)}) using ${preset}: ` + + `quick-commitlint=${nativeResult.status}, commitlint=${nodeResult.status}, ` + + `expectedPass=${testCase.expectedPass}` ); } + + const nativeOutput = `${nativeResult.stdout}${nativeResult.stderr}`; + const nodeOutput = `${nodeResult.stdout}${nodeResult.stderr}`; + if (testCase.expectedRule) { + const marker = `[${testCase.expectedRule}]`; + if (!nativeOutput.includes(marker) || !nodeOutput.includes(marker)) { + throw new Error( + `Missing ${testCase.expectedRule} diagnostic for ${testCase.name}: ` + + `quick-commitlint=${JSON.stringify(nativeOutput)}, commitlint=${JSON.stringify(nodeOutput)}` + ); + } + } + if (testCase.unexpectedRule) { + const marker = `[${testCase.unexpectedRule}]`; + if (nativeOutput.includes(marker) || nodeOutput.includes(marker)) { + throw new Error( + `Unexpected ${testCase.unexpectedRule} diagnostic for ${testCase.name}: ` + + `quick-commitlint=${JSON.stringify(nativeOutput)}, commitlint=${JSON.stringify(nodeOutput)}` + ); + } + } } } diff --git a/scripts/integration.ts b/scripts/integration.ts index 4589fb8..3e5e5af 100644 --- a/scripts/integration.ts +++ b/scripts/integration.ts @@ -14,24 +14,44 @@ function expectStatus(actual: number | null, expected: number, context: string): if (actual !== expected) throw new Error(`${context}: expected status ${expected}, received ${actual}`); } +function expectTimedSummary(output: string, context: string): void { + if (!/\d+\.\d{2} ms/.test(output)) throw new Error(`${context}: missing two-decimal timing.`); +} + try { const valid = run([], 'feat: add parser'); expectStatus(valid.status, 0, 'valid stdin'); - if (valid.stdout !== '' || valid.stderr !== '') throw new Error('Successful lint must be silent.'); + if (valid.stdout !== '') throw new Error('Lint results must be written to stderr.'); + const validOutput = String(valid.stderr); + if (!validOutput.includes('\x1b[32mโœ”\x1b[0m')) throw new Error('Valid summary is not green.'); + if (!validOutput.includes('0 errors') || !validOutput.includes('0 warnings')) { + throw new Error('Valid summary has incorrect counts.'); + } + expectTimedSummary(validOutput, 'valid summary'); - const invalid = run([], 'wat: Add parser.'); + const invalid = run([], 'wat: some message'); expectStatus(invalid.status, 1, 'invalid stdin'); - if (!String(invalid.stderr).includes('error[type-enum]')) throw new Error('Missing type-enum diagnostic.'); - if (!String(invalid.stderr).includes('\x1b[31merror[type-enum]\x1b[0m')) { - throw new Error('Error diagnostic is not red.'); + if (invalid.stdout !== '') throw new Error('Lint results must be written to stderr.'); + const invalidOutput = String(invalid.stderr); + if (!invalidOutput.includes('\x1b[31mโœ–\x1b[0m')) throw new Error('Error symbol is not red.'); + if (!invalidOutput.includes('\x1b[36m[type-enum]\x1b[0m')) throw new Error('Rule identifier is not cyan.'); + if (!invalidOutput.includes('1 error') || invalidOutput.includes('1 errors')) { + throw new Error('Error summary has incorrect singularization.'); } + expectTimedSummary(invalidOutput, 'error summary'); const warning = run([], 'feat: add parser\nbody without blank'); expectStatus(warning.status, 0, 'warning-only stdin'); - if (!String(warning.stderr).includes('warning[body-leading-blank]')) throw new Error('Missing warning.'); - if (!String(warning.stderr).includes('\x1b[33mwarning[body-leading-blank]\x1b[0m')) { - throw new Error('Warning diagnostic is not yellow.'); + if (warning.stdout !== '') throw new Error('Lint results must be written to stderr.'); + const warningOutput = String(warning.stderr); + if (!warningOutput.includes('\x1b[33mโš \x1b[0m')) throw new Error('Warning symbol is not yellow.'); + if (!warningOutput.includes('\x1b[36m[body-leading-blank]\x1b[0m')) { + throw new Error('Warning rule identifier is not cyan.'); + } + if (!warningOutput.includes('1 warning') || warningOutput.includes('1 warnings')) { + throw new Error('Warning summary has incorrect singularization.'); } + expectTimedSummary(warningOutput, 'warning summary'); const messagePath = join(temp, 'COMMIT_EDITMSG'); writeFileSync(messagePath, 'fix(core): handle CRLF\r\n\r\nbody\r\n'); diff --git a/src/config.zig b/src/config.zig index fe0d5d1..f562c08 100644 --- a/src/config.zig +++ b/src/config.zig @@ -302,6 +302,51 @@ test "loads angular and applies overrides" { try std.testing.expectEqual(Severity.err, loaded.value.subject_exclamation_mark.severity); } +test "loads every rule tuple shape and case value" { + var loaded = try parse(std.testing.allocator, + \\{"rules":{ + \\ "body-leading-blank":[1,"never"], + \\ "body-max-line-length":[0], + \\ "footer-leading-blank":[0], + \\ "footer-max-line-length":[2,"always",80], + \\ "header-max-length":[2,"always",90], + \\ "header-trim":[2,"always"], + \\ "scope-case":[2,"always","upper-case"], + \\ "subject-case":[2,"never",["lower-case","upper-case","sentence-case","start-case","pascal-case"]], + \\ "subject-empty":[2,"never"], + \\ "subject-exclamation-mark":[2,"never"], + \\ "subject-full-stop":[2,"never","!"], + \\ "type-case":[2,"always","lower-case"], + \\ "type-empty":[2,"never"], + \\ "type-enum":[2,"always",["feat","fix"]] + \\}} + ); + defer loaded.deinit(); + + try std.testing.expectEqual(Severity.warning, loaded.value.body_leading_blank.severity); + try std.testing.expectEqual(Condition.never, loaded.value.body_leading_blank.condition); + try std.testing.expectEqual(Severity.disabled, loaded.value.body_max_line_length.severity); + try std.testing.expectEqual(Severity.disabled, loaded.value.footer_leading_blank.severity); + try std.testing.expectEqual(@as(usize, 80), loaded.value.footer_max_line_length.value); + try std.testing.expectEqual(@as(usize, 90), loaded.value.header_max_length.value); + try std.testing.expectEqual(Case.upper, loaded.value.scope_case.values[0]); + try std.testing.expectEqualSlices(Case, &.{ .lower, .upper, .sentence, .start, .pascal }, loaded.value.subject_case.values); + try std.testing.expectEqualStrings("!", loaded.value.subject_full_stop.value); + try std.testing.expectEqual(Case.lower, loaded.value.type_case.values[0]); + try std.testing.expectEqualStrings("feat", loaded.value.type_enum.values[0]); + try std.testing.expectEqualStrings("fix", loaded.value.type_enum.values[1]); +} + +test "loads disabled allocated rule tuples" { + var loaded = try parse(std.testing.allocator, + \\{"rules":{"scope-case":[0],"subject-case":[0],"type-enum":[0]}} + ); + defer loaded.deinit(); + try std.testing.expectEqual(Severity.disabled, loaded.value.scope_case.severity); + try std.testing.expectEqual(Severity.disabled, loaded.value.subject_case.severity); + try std.testing.expectEqual(Severity.disabled, loaded.value.type_enum.severity); +} + test "rejects unknown duplicate and malformed configuration" { try std.testing.expectError(error.UnknownField, parse(std.testing.allocator, "{\"unknown\":true}")); try std.testing.expectError(error.DuplicateField, parse(std.testing.allocator, "{\"preset\":\"angular\",\"preset\":\"conventional\"}")); @@ -311,14 +356,46 @@ test "rejects unknown duplicate and malformed configuration" { try std.testing.expectError(error.DuplicateField, parse(std.testing.allocator, "{\"rules\":{\"type-empty\":[0],\"type-empty\":[2,\"never\"]}}")); } +test "rejects every malformed rule tuple shape" { + const malformed = [_][]const u8{ + "{\"rules\":{\"type-empty\":2}}", + "{\"rules\":{\"type-empty\":[\"error\",\"always\"]}}", + "{\"rules\":{\"type-empty\":[3,\"always\"]}}", + "{\"rules\":{\"type-empty\":[2]}}", + "{\"rules\":{\"header-max-length\":[0,\"always\"]}}", + "{\"rules\":{\"type-empty\":[2,2]}}", + "{\"rules\":{\"type-empty\":[2,\"sometimes\"]}}", + "{\"rules\":{\"header-max-length\":[2,\"always\",\"100\"]}}", + "{\"rules\":{\"subject-full-stop\":[2,\"never\",1]}}", + "{\"rules\":{\"subject-full-stop\":[2,\"never\",\"\"]}}", + "{\"rules\":{\"scope-case\":[2,\"always\",1]}}", + "{\"rules\":{\"scope-case\":[2,\"always\",\"camel-case\"]}}", + "{\"rules\":{\"scope-case\":[2,\"always\",[\"lower-case\"]]}}", + "{\"rules\":{\"subject-case\":[2,\"never\",[]]}}", + "{\"rules\":{\"subject-case\":[2,\"never\",[1]]}}", + "{\"rules\":{\"type-enum\":[2,\"always\",\"feat\"]}}", + "{\"rules\":{\"type-enum\":[2,\"always\",[]]}}", + "{\"rules\":{\"type-enum\":[2,\"always\",[1]]}}", + "{\"rules\":{\"type-enum\":[2,\"always\",[\"\"]]}}", + }; + for (malformed) |source| { + try std.testing.expectError(error.InvalidRuleTuple, parse(std.testing.allocator, source)); + } +} + +fn fuzzParse(input: []const u8) void { + var loaded = parse(std.testing.allocator, input) catch return; + loaded.deinit(); +} + test "fuzz strict configuration parser" { + fuzzParse("{}"); try std.testing.fuzz({}, struct { fn testOne(_: void, smith: *std.testing.Smith) !void { var input: [255]u8 = undefined; const len = smith.value(u8); smith.bytes(input[0..len]); - var loaded = parse(std.testing.allocator, input[0..len]) catch return; - loaded.deinit(); + fuzzParse(input[0..len]); } }.testOne, .{}); } diff --git a/src/lint.zig b/src/lint.zig index 1503696..dd79971 100644 --- a/src/lint.zig +++ b/src/lint.zig @@ -365,6 +365,11 @@ test "disabled and never overrides work" { try std.testing.expectEqual(Rule.header_trim, report.issues()[0].rule); } +test "start case rejects a lowercase word after a separator" { + try std.testing.expect(!matchesCase("Some message", .start)); + try std.testing.expect(!matchesCase("123", .start)); +} + fn expectOnlyRule(message: []const u8, rules: config.Config, expected: Rule) !void { const report = try lint(message, rules); try std.testing.expectEqual(@as(usize, 1), report.len); @@ -430,16 +435,22 @@ test "every supported rule can produce a finding" { try expectOnlyRule("fix: subject", rules, .type_enum); } +fn fuzzLint(input: []const u8) !void { + _ = lint(input, config.conventional()) catch |err| switch (err) { + error.InvalidUtf8 => return, + else => return err, + }; +} + test "fuzz message parser" { + try fuzzLint("feat: valid"); + try fuzzLint("feat: \xff"); try std.testing.fuzz({}, struct { fn testOne(_: void, smith: *std.testing.Smith) !void { var input: [255]u8 = undefined; const len = smith.value(u8); smith.bytes(input[0..len]); - _ = lint(input[0..len], config.conventional()) catch |err| switch (err) { - error.InvalidUtf8 => return, - else => return err, - }; + try fuzzLint(input[0..len]); } }.testOne, .{}); } diff --git a/src/main.zig b/src/main.zig index 9197fec..29d3750 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,7 +5,10 @@ const quick = @import("quick_commitlint"); const color = struct { const reset = "\x1b[0m"; const red = "\x1b[31m"; + const green = "\x1b[32m"; const yellow = "\x1b[33m"; + const cyan = "\x1b[36m"; + const dim_cyan = "\x1b[2;36m"; }; const message_limit = 1024 * 1024; @@ -37,6 +40,7 @@ pub fn main(init: std.process.Init) !void { } fn execute(init: std.process.Init, options: cli.Options) !void { + const started: std.Io.Clock.Timestamp = .now(init.io, .awake); const message = if (options.message_path) |path| try readFileAlloc(init.gpa, init.io, path, message_limit) else @@ -59,27 +63,44 @@ fn execute(init: std.process.Init, options: cli.Options) !void { const rules = if (loaded) |*value| value.value else quick.config.conventional(); const report = try quick.linting.lint(message, rules); - if (report.len == 0) return; + const elapsed = started.untilNow(init.io); + const elapsed_ms = @as(f64, @floatFromInt(elapsed.raw.nanoseconds)) / std.time.ns_per_ms; var buffer: [4096]u8 = undefined; var writer = std.Io.File.stderr().writer(init.io, &buffer); for (report.issues()) |issue| { - const level = if (issue.severity == .err) "error" else "warning"; - const level_color = if (issue.severity == .err) color.red else color.yellow; - try writer.interface.print("{s}{s}[{s}]{s}: {s}\n", .{ - level_color, - level, - issue.rule.name(), + const symbol = if (issue.severity == .err) "โœ–" else "โš "; + const symbol_color = if (issue.severity == .err) color.red else color.yellow; + try writer.interface.print(" {s}{s}{s} {s} {s}[{s}]{s}\n", .{ + symbol_color, + symbol, color.reset, issue.message, + color.cyan, + issue.rule.name(), + color.reset, }); } - try writer.interface.print("{s}{d} error(s){s}, {s}{d} warning(s){s}\n", .{ + + if (report.len > 0) try writer.interface.writeByte('\n'); + const summary_symbol = if (report.errors > 0) "โœ–" else if (report.warnings > 0) "โš " else "โœ”"; + const summary_color = if (report.errors > 0) color.red else if (report.warnings > 0) color.yellow else color.green; + const error_suffix = if (report.errors == 1) "" else "s"; + const warning_suffix = if (report.warnings == 1) "" else "s"; + try writer.interface.print(" {s}{s}{s} {s}{d} error{s}{s} ยท {s}{d} warning{s}{s} ยท {s}{d:.2} ms{s}\n", .{ + summary_color, + summary_symbol, + color.reset, color.red, report.errors, + error_suffix, color.reset, color.yellow, report.warnings, + warning_suffix, + color.reset, + color.dim_cyan, + elapsed_ms, color.reset, }); try writer.interface.flush();