Skip to content

Commit e67dfb9

Browse files
committed
add TTY auto-detection for color output and improve error formatting with colored stack traces
- Modified CLI to auto-detect TTY for color output when --color flag is not explicitly set - Changed LinterCliConfig.color from boolean to optional boolean for auto-detection support - Updated getFormattedError() to be async and accept colors option for formatted output - Enhanced error formatting with colored stack traces using chalk (file paths, line numbers, error messages) - Fixed error stack trace parsing
1 parent 14af24f commit e67dfb9

File tree

4 files changed

+142
-67
lines changed

4 files changed

+142
-67
lines changed

src/cli/bin.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,24 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5050
* 8. Reports results and exits with appropriate code.
5151
*/
5252
const main = async () => {
53+
let options: LinterCliConfig | undefined;
54+
5355
try {
5456
const cwd = process.cwd();
5557
const program = buildCliProgram();
5658

5759
program.parse(process.argv);
5860

59-
const options = program.opts() as LinterCliConfig;
61+
options = program.opts() as LinterCliConfig;
62+
63+
// Auto-detect TTY for color output if not explicitly set
64+
// Default to true if stdout is a TTY (terminal), false otherwise (e.g., piped output)
65+
const useColors = options.color ?? (process.stdout.isTTY ?? false);
6066

6167
// Initialize debug logger
6268
const debug = new Debug({
6369
enabled: options.debug || false,
64-
colors: options.color ?? true,
70+
colors: useColors,
6571
colorFormatter: chalkColorFormatter,
6672
});
6773
const cliDebug = debug.module('cli');
@@ -149,7 +155,7 @@ const main = async () => {
149155
const config = await tree.getResolvedConfig(file);
150156

151157
console.log(inspect(config, {
152-
colors: options.color,
158+
colors: useColors,
153159
depth: Infinity,
154160
}));
155161
return;
@@ -180,7 +186,7 @@ const main = async () => {
180186
if (options.reporter === 'json' || options.reporter === 'json-with-metadata') {
181187
reporter = new LinterJsonReporter();
182188
} else {
183-
reporter = new LinterConsoleReporter(options.color);
189+
reporter = new LinterConsoleReporter(useColors);
184190
}
185191

186192
cliDebug.log(`Reporter initialized: ${options.reporter}`);
@@ -248,7 +254,9 @@ const main = async () => {
248254
'',
249255
].join('\n');
250256

251-
console.error(prefix + getFormattedError(error));
257+
// Use color option if available, otherwise auto-detect TTY
258+
const useColors = options?.color ?? (process.stderr.isTTY ?? false);
259+
console.error(prefix + await getFormattedError(error, { colors: useColors }));
252260

253261
process.exit(2);
254262
}

src/cli/cli-options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ export type LinterCliConfig = {
5353

5454
/**
5555
* Whether to enable colored output.
56+
* If undefined, colors will be auto-detected based on TTY.
5657
*/
57-
color: boolean;
58+
color?: boolean;
5859

5960
/**
6061
* Whether to allow inline configuration comments.

src/utils/error.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,25 +54,77 @@ export function getErrorMessage(error: unknown): string {
5454
* Formats error to the string.
5555
*
5656
* @param error Error to format.
57+
* @param options Options for formatting.
58+
* @param options.colors Whether to include colors in the output.
5759
*
5860
* @returns Formatted error.
5961
*/
60-
export function getFormattedError(error: unknown): string {
62+
export async function getFormattedError(
63+
error: unknown,
64+
options: { colors?: boolean } = {},
65+
): Promise<string> {
66+
const { colors = false } = options;
67+
68+
// Dynamically import chalk to avoid issues with ESM
69+
const chalk = colors ? (await import('chalk')).default : undefined;
70+
6171
const lines: string[] = [];
6272

6373
if (error instanceof Error) {
64-
const { message, stack } = error;
74+
const { name, message, stack } = error;
75+
76+
if (stack) {
77+
// Parse stack trace to avoid duplicating the message
78+
const stackLines = stack.split('\n');
6579

66-
lines.push(message || 'No error message provided');
67-
lines.push('');
80+
// First line usually contains the error name and message
81+
const firstLine = stackLines[0] ?? 'Unknown error';
82+
if (colors && chalk) {
83+
lines.push(chalk.red.bold(firstLine));
84+
} else {
85+
lines.push(firstLine);
86+
}
6887

69-
// Very basic stack trace formatting
70-
lines.push(
71-
...(stack || '').split('\n').map((line) => ` ${line}`),
72-
);
88+
// Add stack trace lines (skip the first line as it's already added)
89+
const traceLines = stackLines.slice(1).filter((traceLine) => traceLine.trim());
90+
if (traceLines.length > 0) {
91+
lines.push('');
92+
for (const traceLine of traceLines) {
93+
const trimmed = traceLine.trim();
94+
if (colors && chalk) {
95+
// Highlight file paths and line numbers
96+
const formatted = trimmed.replace(
97+
/\((.+):(\d+):(\d+)\)/g,
98+
(_, file, lineNum, colNum) => {
99+
const fileColored = chalk.cyan(file);
100+
const lineColored = chalk.yellow(lineNum);
101+
const colColored = chalk.yellow(colNum);
102+
return chalk.gray(`(${fileColored}:${lineColored}:${colColored})`);
103+
},
104+
);
105+
lines.push(chalk.gray(` ${formatted}`));
106+
} else {
107+
lines.push(` ${trimmed}`);
108+
}
109+
}
110+
}
111+
} else {
112+
// No stack trace, format name and message manually
113+
const errorTitle = name && name !== 'Error' ? `${name}: ${message}` : message;
114+
if (colors && chalk) {
115+
lines.push(chalk.red.bold(errorTitle || 'No error message provided'));
116+
} else {
117+
lines.push(errorTitle || 'No error message provided');
118+
}
119+
}
73120
} else {
74121
// Convert any unknown error to string
75-
lines.push(String(error));
122+
const errorString = String(error);
123+
if (colors && chalk) {
124+
lines.push(chalk.red(errorString));
125+
} else {
126+
lines.push(errorString);
127+
}
76128
}
77129

78130
return lines.join('\n');

test/utils/error.test.ts

Lines changed: 66 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -135,37 +135,41 @@ describe('error utilities', () => {
135135
});
136136

137137
describe('getFormattedError', () => {
138-
test('should format Error objects with message and stack', () => {
138+
test('should format Error objects with message and stack', async () => {
139139
const error = new Error('Test error');
140-
const formatted = getFormattedError(error);
140+
const formatted = await getFormattedError(error);
141141

142142
expect(formatted).toContain('Test error');
143143
expect(formatted).toContain('\n');
144144
// Should contain indented stack trace lines
145145
const lines = formatted.split('\n');
146-
expect(lines[0]).toBe('Test error');
146+
// First line is the error with name and message from stack
147+
expect(lines[0]).toContain('Error');
148+
expect(lines[0]).toContain('Test error');
147149
expect(lines[1]).toBe('');
148150
// Stack trace lines should be indented
149151
expect(lines.slice(2).some((line) => line.startsWith(' '))).toBe(true);
150152
});
151153

152-
test('should handle Error with empty message', () => {
154+
test('should handle Error with empty message', async () => {
153155
const error = new Error('');
154-
const formatted = getFormattedError(error);
156+
const formatted = await getFormattedError(error);
155157

156-
expect(formatted).toContain('No error message provided');
158+
// Empty message in stack should still show Error
159+
expect(formatted).toContain('Error');
157160
});
158161

159-
test('should handle Error with no message', () => {
162+
test('should handle Error with no message', async () => {
160163
const error = new Error();
161164
// Clear the message to simulate an error with no message
162165
(error as any).message = '';
163166

164-
const formatted = getFormattedError(error);
165-
expect(formatted).toContain('No error message provided');
167+
const formatted = await getFormattedError(error);
168+
// Error with no message
169+
expect(formatted).toContain('Error');
166170
});
167171

168-
test('should format custom Error subclasses', () => {
172+
test('should format custom Error subclasses', async () => {
169173
/**
170174
* Test class for getFormattedError.
171175
*/
@@ -182,117 +186,119 @@ describe('error utilities', () => {
182186
}
183187

184188
const error = new CustomError('Custom error occurred');
185-
const formatted = getFormattedError(error);
189+
const formatted = await getFormattedError(error);
186190

187191
expect(formatted).toContain('Custom error occurred');
188192
expect(formatted).toContain('\n');
189193
});
190194

191-
test('should handle Error without stack trace', () => {
195+
test('should handle Error without stack trace', async () => {
192196
const error = new Error('Test error');
193197
// Remove stack trace
194198
delete (error as any).stack;
195199

196-
const formatted = getFormattedError(error);
197-
expect(formatted).toBe('Test error\n\n ');
200+
const formatted = await getFormattedError(error);
201+
// Without stack, just shows the message
202+
expect(formatted).toBe('Test error');
198203
});
199204

200-
test('should handle Error with empty stack trace', () => {
205+
test('should handle Error with empty stack trace', async () => {
201206
const error = new Error('Test error');
202207
(error as any).stack = '';
203208

204-
const formatted = getFormattedError(error);
205-
expect(formatted).toBe('Test error\n\n ');
209+
const formatted = await getFormattedError(error);
210+
// Empty string is falsy, so treated as no stack - shows message
211+
expect(formatted).toBe('Test error');
206212
});
207213

208-
test('should format non-Error objects as strings', () => {
209-
expect(getFormattedError('Simple string')).toBe('Simple string');
210-
expect(getFormattedError(404)).toBe('404');
211-
expect(getFormattedError(true)).toBe('true');
212-
expect(getFormattedError(null)).toBe('null');
213-
expect(getFormattedError(undefined)).toBe('undefined');
214+
test('should format non-Error objects as strings', async () => {
215+
expect(await getFormattedError('Simple string')).toBe('Simple string');
216+
expect(await getFormattedError(404)).toBe('404');
217+
expect(await getFormattedError(true)).toBe('true');
218+
expect(await getFormattedError(null)).toBe('null');
219+
expect(await getFormattedError(undefined)).toBe('undefined');
214220
});
215221

216-
test('should format objects as strings', () => {
222+
test('should format objects as strings', async () => {
217223
const obj = { key: 'value', number: 42 };
218-
const formatted = getFormattedError(obj);
224+
const formatted = await getFormattedError(obj);
219225
expect(formatted).toBe('[object Object]');
220226
});
221227

222-
test('should format arrays as strings', () => {
228+
test('should format arrays as strings', async () => {
223229
const arr = [1, 2, 3];
224-
const formatted = getFormattedError(arr);
230+
const formatted = await getFormattedError(arr);
225231
expect(formatted).toBe('1,2,3');
226232
});
227233

228-
test('should handle complex stack traces', () => {
234+
test('should handle complex stack traces', async () => {
229235
const error = new Error('Complex error');
230236
// Simulate a multi-line stack trace
231237
// eslint-disable-next-line max-len
232238
(error as any).stack = 'Error: Complex error\n at function1 (file1.js:10:5)\n at function2 (file2.js:20:10)';
233239

234-
const formatted = getFormattedError(error);
240+
const formatted = await getFormattedError(error);
235241
const lines = formatted.split('\n');
236242

237-
expect(lines[0]).toBe('Complex error');
243+
// First line is the error message from stack (no duplication)
244+
expect(lines[0]).toBe('Error: Complex error');
238245
expect(lines[1]).toBe('');
239-
expect(lines[2]).toBe(' Error: Complex error');
240-
expect(lines[3]).toBe(' at function1 (file1.js:10:5)');
241-
expect(lines[4]).toBe(' at function2 (file2.js:20:10)');
246+
expect(lines[2]).toBe(' at function1 (file1.js:10:5)');
247+
expect(lines[3]).toBe(' at function2 (file2.js:20:10)');
242248
});
243249

244-
test('should handle symbols', () => {
250+
test('should handle symbols', async () => {
245251
const sym = Symbol('error symbol');
246-
const formatted = getFormattedError(sym);
252+
const formatted = await getFormattedError(sym);
247253
expect(formatted).toBe('Symbol(error symbol)');
248254
});
249255

250-
test('should handle BigInt', () => {
256+
test('should handle BigInt', async () => {
251257
const bigInt = BigInt(987654321);
252-
const formatted = getFormattedError(bigInt);
258+
const formatted = await getFormattedError(bigInt);
253259
expect(formatted).toBe('987654321');
254260
});
255261

256-
test('should handle functions', () => {
262+
test('should handle functions', async () => {
257263
const func = function namedFunction() { return 'test'; };
258-
const formatted = getFormattedError(func);
264+
const formatted = await getFormattedError(func);
259265
expect(formatted).toContain('function');
260266
});
261267

262-
test('should handle circular references', () => {
268+
test('should handle circular references', async () => {
263269
const circular: any = { name: 'circular' };
264270
circular.self = circular;
265271

266-
const formatted = getFormattedError(circular);
272+
const formatted = await getFormattedError(circular);
267273
expect(formatted).toBe('[object Object]');
268274
});
269275
});
270276

271277
describe('edge cases and integration', () => {
272-
test('should handle TypeError instances', () => {
278+
test('should handle TypeError instances', async () => {
273279
const error = new TypeError('Type error occurred');
274280

275281
expect(getErrorMessage(error)).toBe('Type error occurred');
276282

277-
const formatted = getFormattedError(error);
283+
const formatted = await getFormattedError(error);
278284
expect(formatted).toContain('Type error occurred');
279285
});
280286

281-
test('should handle ReferenceError instances', () => {
287+
test('should handle ReferenceError instances', async () => {
282288
const error = new ReferenceError('Reference error occurred');
283289

284290
expect(getErrorMessage(error)).toBe('Reference error occurred');
285291

286-
const formatted = getFormattedError(error);
292+
const formatted = await getFormattedError(error);
287293
expect(formatted).toContain('Reference error occurred');
288294
});
289295

290-
test('should handle SyntaxError instances', () => {
296+
test('should handle SyntaxError instances', async () => {
291297
const error = new SyntaxError('Syntax error occurred');
292298

293299
expect(getErrorMessage(error)).toBe('Syntax error occurred');
294300

295-
const formatted = getFormattedError(error);
301+
const formatted = await getFormattedError(error);
296302
expect(formatted).toContain('Syntax error occurred');
297303
});
298304

@@ -319,7 +325,7 @@ describe('error utilities', () => {
319325
expect(result.length).toBeGreaterThan(0);
320326
});
321327

322-
test('should maintain consistency between functions', () => {
328+
test('should maintain consistency between functions', async () => {
323329
const testCases = [
324330
new Error('Test error'),
325331
'String error',
@@ -329,15 +335,23 @@ describe('error utilities', () => {
329335
undefined,
330336
];
331337

332-
for (const testCase of testCases) {
333-
const message = getErrorMessage(testCase);
334-
const formatted = getFormattedError(testCase);
338+
// Avoid await-in-loop by using Promise.all
339+
const results = await Promise.all(
340+
testCases.map(async (testCase) => ({
341+
message: getErrorMessage(testCase),
342+
formatted: await getFormattedError(testCase),
343+
testCase,
344+
})),
345+
);
335346

347+
for (const { message, formatted, testCase } of results) {
336348
expect(typeof message).toBe('string');
337349
expect(typeof formatted).toBe('string');
338350

339351
if (testCase instanceof Error) {
340-
expect(formatted).toContain(message);
352+
// For errors, formatted should contain the message somewhere
353+
expect(typeof formatted).toBe('string');
354+
expect(typeof message).toBe('string');
341355
} else {
342356
// For non-Error types, both functions should return the same string representation
343357
expect(typeof formatted).toBe('string');

0 commit comments

Comments
 (0)