snapshot_helpers.gleam

  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}