1import birdie
2import gleam/int
3import gleam/list
4import gleam/string
5import nibble
6import nibble/lexer.{type Span, type Token, Span, Token}
7import node.{type Node}
8
9/// Format a Span as a readable string (kept for compatibility)
10pub fn format_span(span: Span) -> String {
11 let Span(rs, cs, re, ce) = span
12 "Span(row_start: "
13 <> int.to_string(rs)
14 <> ", col_start: "
15 <> int.to_string(cs)
16 <> ", row_end: "
17 <> int.to_string(re)
18 <> ", col_end: "
19 <> int.to_string(ce)
20 <> ")"
21}
22
23/// Format a single Token with visual span rendering
24pub fn format_token(input: String, token: Token(a), index: Int) -> String {
25 let Token(span, lexeme, _) = token
26 let label = "Token " <> int.to_string(index) <> ": '" <> lexeme <> "'"
27 visual_single_span(input, span, label, 0)
28}
29
30/// Format a list of tokens with visual span rendering
31pub fn format_tokens(input: String, tokens: List(Token(a))) -> String {
32 tokens
33 |> list.index_map(fn(token, index) {
34 let Token(span, lexeme, _) = token
35 #(span, "Token " <> int.to_string(index) <> ": '" <> lexeme <> "'")
36 })
37 |> visual_multiple_spans(input, _, 1)
38}
39
40/// Format error reason in a readable way
41fn format_error_reason(reason: nibble.Error(tok)) -> String {
42 case reason {
43 nibble.BadParser(msg) -> "Bad parser: " <> msg
44 nibble.Custom(msg) -> "Custom error: " <> msg
45 nibble.EndOfInput -> "Unexpected end of input"
46 nibble.Expected(expected, got: got) ->
47 "Expected " <> expected <> ", but got " <> string.inspect(got)
48 nibble.Unexpected(tok) -> "Unexpected token: " <> string.inspect(tok)
49 }
50}
51
52/// Format a single DeadEnd error with visual span rendering and input context
53pub fn format_dead_end(
54 input: String,
55 dead_end: nibble.DeadEnd(tok, ctx),
56 index: Int,
57) -> String {
58 let nibble.DeadEnd(span, reason, context) = dead_end
59
60 // Extract the parser name (innermost parser that failed)
61 let parser_name = case context {
62 [#(_, name), ..] -> " [in " <> string.inspect(name) <> "]"
63 [] -> ""
64 }
65
66 let error_header =
67 "Error "
68 <> int.to_string(index + 1)
69 <> parser_name
70 <> ": "
71 <> format_error_reason(reason)
72
73 // Show full parser call stack if there are multiple levels
74 let context_info = case context {
75 [] | [_] -> ""
76 _ -> {
77 let call_chain =
78 context
79 |> list.drop(1) // Skip the first one since it's in the header
80 |> list.map(fn(ctx_item) {
81 let #(_, parser_name) = ctx_item
82 " ↳ called from " <> string.inspect(parser_name)
83 })
84 |> string.join("\n")
85 "\nCall chain:\n" <> call_chain
86 }
87 }
88
89 // Show input with each error for clarity
90 "Input: "
91 <> escape_string(input)
92 <> "\n"
93 <> error_header
94 <> context_info
95 <> "\n"
96 <> visual_single_span(input, span, "error location", 1)
97}
98
99/// Format a list of DeadEnd errors with visual rendering
100pub fn format_dead_ends(
101 input: String,
102 errors: List(nibble.DeadEnd(tok, ctx)),
103) -> String {
104 let count_header = "Number of errors: " <> int.to_string(list.length(errors))
105
106 case list.is_empty(errors) {
107 True -> count_header
108 False -> {
109 count_header
110 <> "\n\n"
111 <> {
112 errors
113 |> list.index_map(fn(dead_end, idx) { format_dead_end(input, dead_end, idx) })
114 |> string.join("\n\n")
115 }
116 }
117 }
118}
119
120/// Format a single Node with index
121pub fn format_node(node: Node, index: Int) -> String {
122 " "
123 <> int.to_string(index + 1)
124 <> ". "
125 <> string.inspect(node)
126 <> "\n"
127}
128
129/// Format a list of Nodes
130pub fn format_nodes(nodes: List(Node)) -> String {
131 "Parsed "
132 <> int.to_string(list.length(nodes))
133 <> " node(s):\n"
134 <> {
135 nodes
136 |> list.index_map(fn(n, idx) { format_node(n, idx) })
137 |> string.join("")
138 }
139}
140
141/// Create a snapshot of lexer output showing input and tokens
142pub fn snap_lexer_output(
143 input: String,
144 tokens: List(Token(a)),
145 title: String,
146) -> Nil {
147 let snap =
148 "Input: "
149 <> escape_string(input)
150 <> "\n\n"
151 <> format_tokens(input, tokens)
152
153 birdie.snap(snap, title: title)
154}
155
156/// Create a snapshot of a parse result (success or error)
157pub fn snap_parse_result_nodes(
158 input: String,
159 result: Result(List(Node), List(nibble.DeadEnd(tok, ctx))),
160 title: String,
161) -> Nil {
162 let snap = case result {
163 Ok(nodes) ->
164 "Input: "
165 <> escape_string(input)
166 <> "\n"
167 <> format_nodes(nodes)
168 Error(errors) ->
169 // Input is shown with each error, so no need to show it at the top level
170 format_dead_ends(input, errors)
171 }
172
173 birdie.snap(snap, title: title)
174}
175
176/// Create a snapshot of a parse error showing input and errors
177pub fn snap_parse_error(
178 input: String,
179 errors: List(nibble.DeadEnd(tok, ctx)),
180 title: String,
181) -> Nil {
182 snap_parse_result_nodes(input, Error(errors), title)
183}
184
185/// Create a snapshot of successful parse showing input and result
186pub fn snap_parse_success(
187 input: String,
188 nodes: List(Node),
189 title: String,
190) -> Nil {
191 snap_parse_result_nodes(input, Ok(nodes), title)
192}
193
194/// Create a snapshot for a Result type (success or error)
195pub fn snap_parse_result(
196 input: String,
197 result: Result(a, List(nibble.DeadEnd(tok, ctx))),
198 title: String,
199 format_success: fn(a) -> String,
200) -> Nil {
201 let snap = case result {
202 Ok(value) -> {
203 "Input: "
204 <> escape_string(input)
205 <> "\n\nSuccess:\n"
206 <> format_success(value)
207 }
208 // Input is shown with each error, so no need to show it at the top level
209 Error(errors) -> format_dead_ends(input, errors)
210 }
211
212 birdie.snap(snap, title: title)
213}
214
215/// Escape special characters in strings for readable snapshots
216fn escape_string(s: String) -> String {
217 s
218 |> string.replace("\\", "\\\\")
219 |> string.replace("\n", "\\n")
220 |> string.replace("\r", "\\r")
221 |> string.replace("\t", "\\t")
222}
223
224// ============================================================================
225// Visual Span Rendering Functions
226// ============================================================================
227
228/// Split input into indexed lines (1-based)
229fn split_into_lines(input: String) -> List(#(Int, String)) {
230 input
231 |> string.split("\n")
232 |> list.index_map(fn(line, idx) { #(idx + 1, line) })
233}
234
235/// Calculate the width needed for the gutter (line numbers)
236fn calculate_gutter_width(max_line_num: Int) -> Int {
237 int.to_string(max_line_num)
238 |> string.length()
239}
240
241/// Render a line with its gutter (line number and separator)
242fn render_line_with_gutter(
243 line_num: Int,
244 content: String,
245 gutter_width: Int,
246) -> String {
247 let line_str = int.to_string(line_num)
248 let padding = string.repeat(" ", gutter_width - string.length(line_str))
249 padding <> line_str <> " │ " <> make_visible(content)
250}
251
252/// Render a continuation marker in the gutter
253fn render_continuation_gutter(gutter_width: Int) -> String {
254 string.repeat(" ", gutter_width) <> " │"
255}
256
257/// Make invisible characters visible by escaping them
258/// This ensures users can see what they're working with in visual spans
259fn make_visible(s: String) -> String {
260 s
261 |> string.replace("\\", "\\\\")
262 |> string.replace("\r", "\\r")
263 |> string.replace("\n", "\\n")
264 |> string.replace("\t", "\\t")
265}
266
267/// Calculate visible column position accounting for escaped characters
268/// Maps original column position to position in the escaped/visible string
269fn calculate_visual_column(line_content: String, original_col: Int) -> Int {
270 line_content
271 |> string.to_graphemes()
272 |> list.take(original_col - 1)
273 |> list.fold(0, fn(acc, char) {
274 case char {
275 "\\" -> acc + 2 // Displayed as \\
276 "\r" -> acc + 2 // Displayed as \r
277 "\n" -> acc + 2 // Displayed as \n
278 "\t" -> acc + 2 // Displayed as \t
279 _ -> acc + 1
280 }
281 })
282 |> fn(pos) { pos + 1 } // Add 1 because columns are 1-indexed
283}
284
285/// Generate a marker line with box-drawing characters
286/// Returns a string like " ───┬───" for a span
287fn visual_span_marker(
288 gutter_width: Int,
289 start_col: Int,
290 end_col: Int,
291) -> String {
292 let gutter = string.repeat(" ", gutter_width + 3)
293 let before = string.repeat(" ", start_col - 1)
294 let marker_width = end_col - start_col
295
296 case marker_width {
297 0 -> gutter <> before <> "┬"
298 1 -> gutter <> before <> "┬"
299 _ -> {
300 let half = marker_width / 2
301 let left = string.repeat("─", half)
302 let right = string.repeat("─", marker_width - half - 1)
303 gutter <> before <> left <> "┬" <> right
304 }
305 }
306}
307
308/// Generate a label line with box-drawing characters
309/// Returns a string like " └─ label text"
310fn visual_span_label(gutter_width: Int, col: Int, label: String) -> String {
311 let gutter = string.repeat(" ", gutter_width + 3)
312 let before = string.repeat(" ", col - 1)
313 gutter <> before <> "└─ " <> label
314}
315
316
317/// Render a single span visually with context lines
318pub fn visual_single_span(
319 input: String,
320 span: Span,
321 label: String,
322 context_lines: Int,
323) -> String {
324 let Span(row_start, col_start, row_end, col_end) = span
325 let lines = split_into_lines(input)
326 let max_line = list.length(lines)
327 let gutter_width = calculate_gutter_width(max_line)
328
329 // Determine which lines to show
330 let first_line = int.max(1, row_start - context_lines)
331 let last_line = int.min(max_line, row_end + context_lines)
332
333 // Build the output
334 let content_lines =
335 lines
336 |> list.filter(fn(line) {
337 let #(num, _) = line
338 num >= first_line && num <= last_line
339 })
340 |> list.flat_map(fn(line) {
341 let #(num, content) = line
342 let line_str = render_line_with_gutter(num, content, gutter_width)
343
344 // Add markers for lines within the span
345 case num >= row_start && num <= row_end {
346 True -> {
347 case num == row_start, num == row_end {
348 // Single line span
349 True, True -> {
350 let visual_start = calculate_visual_column(content, col_start)
351 let visual_end = calculate_visual_column(content, col_end)
352 let marker = visual_span_marker(gutter_width, visual_start, visual_end)
353 let visual_center = visual_start + { visual_end - visual_start } / 2
354 let label_line =
355 visual_span_label(gutter_width, visual_center, label)
356 [line_str, marker, label_line]
357 }
358 // First line of multi-line span
359 True, False -> {
360 let visual_start = calculate_visual_column(content, col_start)
361 let visible_len = string.length(make_visible(content))
362 let marker = visual_span_marker(gutter_width, visual_start, visible_len + 1)
363 [line_str, render_continuation_gutter(gutter_width) <> " " <> marker]
364 }
365 // Last line of multi-line span
366 False, True -> {
367 let visual_end = calculate_visual_column(content, col_end)
368 let marker = visual_span_marker(gutter_width, 1, visual_end)
369 let label_line =
370 visual_span_label(gutter_width, visual_end / 2, label)
371 [line_str, marker, label_line]
372 }
373 // Middle line of multi-line span
374 False, False -> {
375 let visible_len = string.length(make_visible(content))
376 let marker = visual_span_marker(gutter_width, 1, visible_len + 1)
377 [line_str, render_continuation_gutter(gutter_width) <> " " <> marker]
378 }
379 }
380 }
381 False -> [line_str]
382 }
383 })
384
385 string.join(content_lines, "\n")
386}
387
388/// Render multiple spans visually (stacked vertically for same-line spans)
389pub fn visual_multiple_spans(
390 input: String,
391 spans: List(#(Span, String)),
392 context_lines: Int,
393) -> String {
394 case spans {
395 [] -> ""
396 [single] -> {
397 let #(span, label) = single
398 visual_single_span(input, span, label, context_lines)
399 }
400 _ -> {
401 // For now, render each span separately and join with blank lines
402 spans
403 |> list.map(fn(span_label) {
404 let #(span, label) = span_label
405 visual_single_span(input, span, label, context_lines)
406 })
407 |> string.join("\n\n")
408 }
409 }
410}