@@ -172,3 +172,81 @@ export const formatErrorCompact = (f: Failure): string => {
172172 const context = f . stack . length > 0 ? ` (${ f . stack [ 0 ] . label } )` : "" ;
173173 return `expected ${ f . expected } ${ context } at ${ f . location . line } :${ f . location . column } ` ;
174174} ;
175+
176+ const expandTabs = ( s : string , tabWidth : number ) : string => {
177+ if ( tabWidth <= 0 ) return s ;
178+ return s . replaceAll ( "\t" , " " . repeat ( tabWidth ) ) ;
179+ } ;
180+
181+ const ansi = {
182+ reset : "\x1b[0m" ,
183+ dim : "\x1b[2m" ,
184+ bold : "\x1b[1m" ,
185+ red : "\x1b[31m" ,
186+ yellow : "\x1b[33m" ,
187+ } ;
188+
189+ export type FormatErrorSnippetOptions = Readonly < {
190+ /** Include N lines before and after the error line. Default: 1 */
191+ contextLines ?: number ;
192+ /** Expand tabs to this many spaces. Default: 2 */
193+ tabWidth ?: number ;
194+ /** Add ANSI color codes. Default: false */
195+ color ?: boolean ;
196+ } > ;
197+
198+ /**
199+ * Format a failure with a small source snippet and caret indicator.
200+ */
201+ export const formatErrorSnippet = (
202+ f : Failure ,
203+ opts : FormatErrorSnippetOptions = { } ,
204+ ) : string => {
205+ const contextLines = opts . contextLines ?? 1 ;
206+ const tabWidth = opts . tabWidth ?? 2 ;
207+ const color = opts . color ?? false ;
208+
209+ const lines = f . ctx . text . split ( "\n" ) . map ( ( l ) =>
210+ l . endsWith ( "\r" ) ? l . slice ( 0 , - 1 ) : l
211+ ) ;
212+ const lineIdx = Math . max ( 0 , Math . min ( lines . length - 1 , f . location . line - 1 ) ) ;
213+
214+ const startLine = Math . max ( 0 , lineIdx - contextLines ) ;
215+ const endLine = Math . min ( lines . length - 1 , lineIdx + contextLines ) ;
216+ const maxLineNo = endLine + 1 ;
217+ const lineNoWidth = String ( maxLineNo ) . length ;
218+
219+ const header =
220+ `expected ${ f . expected } at line ${ f . location . line } , column ${ f . location . column } ` ;
221+ const out : string [ ] = [
222+ color ? `${ ansi . red } ${ header } ${ ansi . reset } ` : header ,
223+ ] ;
224+
225+ for ( let i = startLine ; i <= endLine ; i ++ ) {
226+ const rawLine = lines [ i ] ?? "" ;
227+ const printedLine = expandTabs ( rawLine , tabWidth ) ;
228+ const lineNo = String ( i + 1 ) . padStart ( lineNoWidth , " " ) ;
229+
230+ out . push (
231+ color
232+ ? `${ ansi . dim } ${ lineNo } ${ ansi . reset } | ${ printedLine } `
233+ : `${ lineNo } | ${ printedLine } ` ,
234+ ) ;
235+
236+ if ( i === lineIdx ) {
237+ // 1-based column -> prefix length in original line (clamped).
238+ const rawPrefixLen = Math . max (
239+ 0 ,
240+ Math . min ( rawLine . length , f . location . column - 1 ) ,
241+ ) ;
242+ const rawPrefix = rawLine . slice ( 0 , rawPrefixLen ) ;
243+ const caretPos = expandTabs ( rawPrefix , tabWidth ) . length ;
244+
245+ const gutter = " " . repeat ( lineNoWidth ) ;
246+ const caretLine = `${ gutter } | ${ " " . repeat ( caretPos ) } ^` ;
247+ out . push ( color ? `${ ansi . yellow } ${ caretLine } ${ ansi . reset } ` : caretLine ) ;
248+ }
249+ }
250+
251+ return out . join ( "\n" ) ;
252+ } ;
0 commit comments