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 error showing input and errors
157pub fn snap_parse_error(
158  input: String,
159  errors: List(nibble.DeadEnd(tok, ctx)),
160  title: String,
161) -> Nil {
162  // Input is shown with each error, so no need to show it at the top level
163  let snap = format_dead_ends(input, errors)
164
165  birdie.snap(snap, title: title)
166}
167
168/// Create a snapshot of successful parse showing input and result
169pub fn snap_parse_success(
170  input: String,
171  nodes: List(Node),
172  title: String,
173) -> Nil {
174  let snap =
175    "Input: "
176    <> escape_string(input)
177    <> "\n"
178    <> format_nodes(nodes)
179
180  birdie.snap(snap, title: title)
181}
182
183/// Create a snapshot for a Result type (success or error)
184pub fn snap_parse_result(
185  input: String,
186  result: Result(a, List(nibble.DeadEnd(tok, ctx))),
187  title: String,
188  format_success: fn(a) -> String,
189) -> Nil {
190  let snap = case result {
191    Ok(value) -> {
192      "Input: "
193      <> escape_string(input)
194      <> "\n\nSuccess:\n"
195      <> format_success(value)
196    }
197    // Input is shown with each error, so no need to show it at the top level
198    Error(errors) -> format_dead_ends(input, errors)
199  }
200
201  birdie.snap(snap, title: title)
202}
203
204/// Escape special characters in strings for readable snapshots
205fn escape_string(s: String) -> String {
206  s
207  |> string.replace("\\", "\\\\")
208  |> string.replace("\n", "\\n")
209  |> string.replace("\r", "\\r")
210  |> string.replace("\t", "\\t")
211}
212
213// ============================================================================
214// Visual Span Rendering Functions
215// ============================================================================
216
217/// Split input into indexed lines (1-based)
218fn split_into_lines(input: String) -> List(#(Int, String)) {
219  input
220  |> string.split("\n")
221  |> list.index_map(fn(line, idx) { #(idx + 1, line) })
222}
223
224/// Calculate the width needed for the gutter (line numbers)
225fn calculate_gutter_width(max_line_num: Int) -> Int {
226  int.to_string(max_line_num)
227  |> string.length()
228}
229
230/// Render a line with its gutter (line number and separator)
231fn render_line_with_gutter(
232  line_num: Int,
233  content: String,
234  gutter_width: Int,
235) -> String {
236  let line_str = int.to_string(line_num)
237  let padding = string.repeat(" ", gutter_width - string.length(line_str))
238  padding <> line_str <> "" <> make_visible(content)
239}
240
241/// Render a continuation marker in the gutter
242fn render_continuation_gutter(gutter_width: Int) -> String {
243  string.repeat(" ", gutter_width) <> ""
244}
245
246/// Make invisible characters visible by escaping them
247/// This ensures users can see what they're working with in visual spans
248fn make_visible(s: String) -> String {
249  s
250  |> string.replace("\\", "\\\\")
251  |> string.replace("\r", "\\r")
252  |> string.replace("\n", "\\n")
253  |> string.replace("\t", "\\t")
254}
255
256/// Calculate visible column position accounting for escaped characters
257/// Maps original column position to position in the escaped/visible string
258fn calculate_visual_column(line_content: String, original_col: Int) -> Int {
259  line_content
260  |> string.to_graphemes()
261  |> list.take(original_col - 1)
262  |> list.fold(0, fn(acc, char) {
263    case char {
264      "\\" -> acc + 2  // Displayed as \\
265      "\r" -> acc + 2  // Displayed as \r
266      "\n" -> acc + 2  // Displayed as \n
267      "\t" -> acc + 2  // Displayed as \t
268      _ -> acc + 1
269    }
270  })
271  |> fn(pos) { pos + 1 }  // Add 1 because columns are 1-indexed
272}
273
274/// Generate a marker line with box-drawing characters
275/// Returns a string like "   ───┬───" for a span
276fn visual_span_marker(
277  gutter_width: Int,
278  start_col: Int,
279  end_col: Int,
280) -> String {
281  let gutter = string.repeat(" ", gutter_width + 3)
282  let before = string.repeat(" ", start_col - 1)
283  let marker_width = end_col - start_col
284
285  case marker_width {
286    0 -> gutter <> before <> ""
287    1 -> gutter <> before <> ""
288    _ -> {
289      let half = marker_width / 2
290      let left = string.repeat("", half)
291      let right = string.repeat("", marker_width - half - 1)
292      gutter <> before <> left <> "" <> right
293    }
294  }
295}
296
297/// Generate a label line with box-drawing characters
298/// Returns a string like "      └─ label text"
299fn visual_span_label(gutter_width: Int, col: Int, label: String) -> String {
300  let gutter = string.repeat(" ", gutter_width + 3)
301  let before = string.repeat(" ", col - 1)
302  gutter <> before <> "└─ " <> label
303}
304
305/// Get the center column of a span for label positioning
306fn span_center_col(span: Span) -> Int {
307  let Span(_, cs, _, ce) = span
308  cs + { ce - cs } / 2
309}
310
311/// Render a single span visually with context lines
312pub fn visual_single_span(
313  input: String,
314  span: Span,
315  label: String,
316  context_lines: Int,
317) -> String {
318  let Span(row_start, col_start, row_end, col_end) = span
319  let lines = split_into_lines(input)
320  let max_line = list.length(lines)
321  let gutter_width = calculate_gutter_width(max_line)
322
323  // Determine which lines to show
324  let first_line = int.max(1, row_start - context_lines)
325  let last_line = int.min(max_line, row_end + context_lines)
326
327  // Build the output
328  let content_lines =
329    lines
330    |> list.filter(fn(line) {
331      let #(num, _) = line
332      num >= first_line && num <= last_line
333    })
334    |> list.flat_map(fn(line) {
335      let #(num, content) = line
336      let line_str = render_line_with_gutter(num, content, gutter_width)
337
338      // Add markers for lines within the span
339      case num >= row_start && num <= row_end {
340        True -> {
341          case num == row_start, num == row_end {
342            // Single line span
343            True, True -> {
344              let visual_start = calculate_visual_column(content, col_start)
345              let visual_end = calculate_visual_column(content, col_end)
346              let marker = visual_span_marker(gutter_width, visual_start, visual_end)
347              let visual_center = visual_start + { visual_end - visual_start } / 2
348              let label_line =
349                visual_span_label(gutter_width, visual_center, label)
350              [line_str, marker, label_line]
351            }
352            // First line of multi-line span
353            True, False -> {
354              let visual_start = calculate_visual_column(content, col_start)
355              let visible_len = string.length(make_visible(content))
356              let marker = visual_span_marker(gutter_width, visual_start, visible_len + 1)
357              [line_str, render_continuation_gutter(gutter_width) <> "  " <> marker]
358            }
359            // Last line of multi-line span
360            False, True -> {
361              let visual_end = calculate_visual_column(content, col_end)
362              let marker = visual_span_marker(gutter_width, 1, visual_end)
363              let label_line =
364                visual_span_label(gutter_width, visual_end / 2, label)
365              [line_str, marker, label_line]
366            }
367            // Middle line of multi-line span
368            False, False -> {
369              let visible_len = string.length(make_visible(content))
370              let marker = visual_span_marker(gutter_width, 1, visible_len + 1)
371              [line_str, render_continuation_gutter(gutter_width) <> "  " <> marker]
372            }
373          }
374        }
375        False -> [line_str]
376      }
377    })
378
379  string.join(content_lines, "\n")
380}
381
382/// Render multiple spans visually (stacked vertically for same-line spans)
383pub fn visual_multiple_spans(
384  input: String,
385  spans: List(#(Span, String)),
386  context_lines: Int,
387) -> String {
388  case spans {
389    [] -> ""
390    [single] -> {
391      let #(span, label) = single
392      visual_single_span(input, span, label, context_lines)
393    }
394    _ -> {
395      // For now, render each span separately and join with blank lines
396      spans
397      |> list.map(fn(span_label) {
398        let #(span, label) = span_label
399        visual_single_span(input, span, label, context_lines)
400      })
401      |> string.join("\n\n")
402    }
403  }
404}