Skip to content

Commit 3d99b7b

Browse files
feat(cli): render decode error with line context, source, and caret
1 parent 1181b14 commit 3d99b7b

5 files changed

Lines changed: 210 additions & 25 deletions

File tree

packages/cli/src/conversion.ts

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -85,38 +85,27 @@ export async function decodeToJson(config: {
8585
if (config.expandPaths === 'safe') {
8686
const toonContent = await readInput(config.input)
8787

88-
let data: unknown
89-
try {
90-
const decodeOptions: DecodeOptions = {
91-
indent: config.indent,
92-
strict: config.strict,
93-
expandPaths: config.expandPaths,
94-
}
95-
data = decode(toonContent, decodeOptions)
96-
}
97-
catch (error) {
98-
throw new Error(`Failed to decode TOON: ${error instanceof Error ? error.message : String(error)}`)
88+
const decodeOptions: DecodeOptions = {
89+
indent: config.indent,
90+
strict: config.strict,
91+
expandPaths: config.expandPaths,
9992
}
93+
const data = decode(toonContent, decodeOptions)
10094

10195
await writeStreamingJson(jsonStringifyLines(data, config.indent), config.output)
10296
}
10397
else {
104-
try {
105-
const lineSource = readLinesFromSource(config.input)
98+
const lineSource = readLinesFromSource(config.input)
10699

107-
const decodeStreamOptions: DecodeStreamOptions = {
108-
indent: config.indent,
109-
strict: config.strict,
110-
}
100+
const decodeStreamOptions: DecodeStreamOptions = {
101+
indent: config.indent,
102+
strict: config.strict,
103+
}
111104

112-
const events = decodeStream(lineSource, decodeStreamOptions)
113-
const jsonChunks = jsonStreamFromEvents(events, config.indent)
105+
const events = decodeStream(lineSource, decodeStreamOptions)
106+
const jsonChunks = jsonStreamFromEvents(events, config.indent)
114107

115-
await writeStreamingJson(jsonChunks, config.output)
116-
}
117-
catch (error) {
118-
throw new Error(`Failed to decode TOON: ${error instanceof Error ? error.message : String(error)}`)
119-
}
108+
await writeStreamingJson(jsonChunks, config.output)
120109
}
121110

122111
if (config.output) {

packages/cli/src/format-error.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ToonDecodeError } from '../../toon/src/index.ts'
2+
3+
export interface FormatErrorOptions {
4+
isVerbose: boolean
5+
}
6+
7+
// #region Public API
8+
9+
export function formatError(error: unknown, options: FormatErrorOptions): string {
10+
const sections: string[] = []
11+
12+
if (error instanceof ToonDecodeError && error.line !== undefined) {
13+
sections.push(formatDecodeError(error))
14+
}
15+
else {
16+
sections.push(String(error))
17+
}
18+
19+
if (options.isVerbose) {
20+
const causeChain = formatCauseChain(error)
21+
if (causeChain) {
22+
sections.push(causeChain)
23+
}
24+
25+
if (error instanceof Error && error.stack) {
26+
sections.push(error.stack)
27+
}
28+
}
29+
30+
return sections.join('\n\n')
31+
}
32+
33+
// #endregion
34+
35+
// #region Internal renderers
36+
37+
function formatDecodeError(error: ToonDecodeError): string {
38+
const linePrefix = `Line ${error.line}: `
39+
const messageWithoutPrefix = error.message.startsWith(linePrefix)
40+
? error.message.slice(linePrefix.length)
41+
: error.message
42+
43+
const header = `Failed to decode TOON at line ${error.line}: ${messageWithoutPrefix}`
44+
45+
if (error.source === undefined) {
46+
return header
47+
}
48+
49+
const visibleSource = error.source.replace(/\t/g, '→')
50+
const firstNonWhitespaceIndex = visibleSource.search(/\S/)
51+
const gutter = ` ${error.line} | `
52+
const caretIndent = ' '.repeat(gutter.length + Math.max(firstNonWhitespaceIndex, 0))
53+
54+
return `${header}\n\n${gutter}${visibleSource}\n${caretIndent}^`
55+
}
56+
57+
function formatCauseChain(error: unknown): string {
58+
const causeLines: string[] = []
59+
let current: unknown = error instanceof Error ? error.cause : undefined
60+
61+
while (current instanceof Error) {
62+
const name = current.name || 'Error'
63+
causeLines.push(`Caused by: ${name}: ${current.message}`)
64+
current = current.cause
65+
}
66+
67+
return causeLines.join('\n')
68+
}
69+
70+
// #endregion

packages/cli/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { consola } from 'consola'
88
import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src/index.ts'
99
import pkg from '../package.json' with { type: 'json' }
1010
import { decodeToJson, encodeToToon } from './conversion.ts'
11+
import { formatError } from './format-error.ts'
1112
import { detectMode } from './utils.ts'
1213

1314
const { name, version } = pkg
@@ -67,6 +68,11 @@ const args: ArgsDef = {
6768
description: 'Show token statistics',
6869
default: false,
6970
},
71+
verbose: {
72+
type: 'boolean',
73+
description: 'Show full stack traces and cause chains for errors',
74+
default: false,
75+
},
7076
} as const
7177

7278
export const mainCommand: CommandDef<ArgsDef> = defineCommand({
@@ -142,7 +148,7 @@ export const mainCommand: CommandDef<ArgsDef> = defineCommand({
142148
}
143149
}
144150
catch (error) {
145-
consola.error(error)
151+
consola.error(formatError(error, { isVerbose: args.verbose === true }))
146152
process.exit(1)
147153
}
148154
},
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { ToonDecodeError } from '../../toon/src/index'
3+
import { formatError } from '../src/format-error'
4+
5+
describe('formatError', () => {
6+
it('renders a decode error with line and source as a header, source line, and caret', () => {
7+
const error = new ToonDecodeError(
8+
'Tabs are not allowed in indentation in strict mode',
9+
{ line: 2, source: '\tb: 1' },
10+
)
11+
12+
const output = formatError(error, { isVerbose: false })
13+
14+
expect(output).toBe(
15+
'Failed to decode TOON at line 2: Tabs are not allowed in indentation in strict mode\n'
16+
+ '\n'
17+
+ ' 2 | →b: 1\n'
18+
+ ' ^',
19+
)
20+
})
21+
22+
it('renders a decode error without source as a header only', () => {
23+
const error = new ToonDecodeError('Something went wrong', { line: 5 })
24+
25+
const output = formatError(error, { isVerbose: false })
26+
27+
expect(output).toBe('Failed to decode TOON at line 5: Something went wrong')
28+
})
29+
30+
it('appends the cause chain under verbose mode', () => {
31+
const cause = new SyntaxError('Unterminated string: missing closing quote')
32+
const error = new ToonDecodeError(
33+
'Unterminated string: missing closing quote',
34+
{ line: 2, source: 'greeting: "hello', cause },
35+
)
36+
37+
const output = formatError(error, { isVerbose: true })
38+
39+
expect(output).toContain('Failed to decode TOON at line 2:')
40+
expect(output).toContain(' 2 | greeting: "hello')
41+
expect(output).toContain('Caused by: SyntaxError: Unterminated string: missing closing quote')
42+
})
43+
44+
it('appends the stack trace under verbose mode and omits it otherwise', () => {
45+
const error = new ToonDecodeError('Boom', { line: 1, source: 'x' })
46+
error.stack = 'ToonDecodeError: Line 1: Boom\n at fakeFrame (file.ts:1:1)'
47+
48+
const verbose = formatError(error, { isVerbose: true })
49+
const quiet = formatError(error, { isVerbose: false })
50+
51+
expect(verbose).toContain('at fakeFrame (file.ts:1:1)')
52+
expect(quiet).not.toContain('at fakeFrame')
53+
})
54+
55+
it('renders a generic Error as its message only when not verbose', () => {
56+
const error = new Error('something went wrong')
57+
58+
const output = formatError(error, { isVerbose: false })
59+
60+
expect(output).toBe('Error: something went wrong')
61+
})
62+
63+
it('places the caret under the first non-whitespace character of the source line', () => {
64+
const error = new ToonDecodeError(
65+
'Indentation must be exact multiple of 2, but found 3 spaces',
66+
{ line: 2, source: ' b: 1' },
67+
)
68+
69+
const output = formatError(error, { isVerbose: false })
70+
71+
expect(output).toBe(
72+
'Failed to decode TOON at line 2: Indentation must be exact multiple of 2, but found 3 spaces\n'
73+
+ '\n'
74+
+ ' 2 | b: 1\n'
75+
+ ' ^',
76+
)
77+
})
78+
})

packages/cli/test/index.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,48 @@ describe('toon CLI', () => {
230230
cleanup()
231231
}
232232
})
233+
234+
it('renders a TOON decode error with line context, source, and caret', async () => {
235+
const cleanup = mockStdin('a:\n\tb: 1\n')
236+
237+
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
238+
const exitSpy = vi.mocked(process.exit)
239+
240+
try {
241+
await runCli({ rawArgs: ['--decode'] })
242+
243+
expect(exitSpy).toHaveBeenCalledWith(1)
244+
const errorCall = consolaError.mock.calls.at(0)
245+
expect(errorCall).toBeDefined()
246+
const [rendered] = errorCall!
247+
expect(rendered).toEqual(expect.stringContaining('Failed to decode TOON at line 2:'))
248+
expect(rendered).toEqual(expect.stringContaining(' 2 | →b: 1'))
249+
expect(rendered).toEqual(expect.stringContaining(' ^'))
250+
expect(rendered).not.toEqual(expect.stringMatching(/^\s+at \S+/m))
251+
}
252+
finally {
253+
cleanup()
254+
}
255+
})
256+
257+
it('includes the stack trace when --verbose is passed', async () => {
258+
const cleanup = mockStdin('a:\n\tb: 1\n')
259+
260+
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
261+
262+
try {
263+
await runCli({ rawArgs: ['--decode', '--verbose'] })
264+
265+
const errorCall = consolaError.mock.calls.at(0)
266+
expect(errorCall).toBeDefined()
267+
const [rendered] = errorCall!
268+
expect(rendered).toEqual(expect.stringContaining('Failed to decode TOON at line 2:'))
269+
expect(rendered).toEqual(expect.stringMatching(/at \S+/))
270+
}
271+
finally {
272+
cleanup()
273+
}
274+
})
233275
})
234276

235277
describe('stdin with options', () => {

0 commit comments

Comments
 (0)