import birdie import gleam/int import gleam/list import gleam/string import nibble import nibble/lexer.{type Span, type Token, Span, Token} import node.{type Node} /// Format a Span as a readable string (kept for compatibility) pub fn format_span(span: Span) -> String { let Span(rs, cs, re, ce) = span "Span(row_start: " <> int.to_string(rs) <> ", col_start: " <> int.to_string(cs) <> ", row_end: " <> int.to_string(re) <> ", col_end: " <> int.to_string(ce) <> ")" } /// Format a single Token with visual span rendering pub fn format_token(input: String, token: Token(a), index: Int) -> String { let Token(span, lexeme, _) = token let label = "Token " <> int.to_string(index) <> ": '" <> lexeme <> "'" visual_single_span(input, span, label, 0) } /// Format a list of tokens with visual span rendering pub fn format_tokens(input: String, tokens: List(Token(a))) -> String { tokens |> list.index_map(fn(token, index) { let Token(span, lexeme, _) = token #(span, "Token " <> int.to_string(index) <> ": '" <> lexeme <> "'") }) |> visual_multiple_spans(input, _, 1) } /// Format error reason in a readable way fn format_error_reason(reason: nibble.Error(tok)) -> String { case reason { nibble.BadParser(msg) -> "Bad parser: " <> msg nibble.Custom(msg) -> "Custom error: " <> msg nibble.EndOfInput -> "Unexpected end of input" nibble.Expected(expected, got: got) -> "Expected " <> expected <> ", but got " <> string.inspect(got) nibble.Unexpected(tok) -> "Unexpected token: " <> string.inspect(tok) } } /// Format a single DeadEnd error with visual span rendering and input context pub fn format_dead_end( input: String, dead_end: nibble.DeadEnd(tok, ctx), index: Int, ) -> String { let nibble.DeadEnd(span, reason, context) = dead_end // Extract the parser name (innermost parser that failed) let parser_name = case context { [#(_, name), ..] -> " [in " <> string.inspect(name) <> "]" [] -> "" } let error_header = "Error " <> int.to_string(index + 1) <> parser_name <> ": " <> format_error_reason(reason) // Show full parser call stack if there are multiple levels let context_info = case context { [] | [_] -> "" _ -> { let call_chain = context |> list.drop(1) // Skip the first one since it's in the header |> list.map(fn(ctx_item) { let #(_, parser_name) = ctx_item " ↳ called from " <> string.inspect(parser_name) }) |> string.join("\n") "\nCall chain:\n" <> call_chain } } // Show input with each error for clarity "Input: " <> escape_string(input) <> "\n" <> error_header <> context_info <> "\n" <> visual_single_span(input, span, "error location", 1) } /// Format a list of DeadEnd errors with visual rendering pub fn format_dead_ends( input: String, errors: List(nibble.DeadEnd(tok, ctx)), ) -> String { let count_header = "Number of errors: " <> int.to_string(list.length(errors)) case list.is_empty(errors) { True -> count_header False -> { count_header <> "\n\n" <> { errors |> list.index_map(fn(dead_end, idx) { format_dead_end(input, dead_end, idx) }) |> string.join("\n\n") } } } } /// Format a single Node with index pub fn format_node(node: Node, index: Int) -> String { " " <> int.to_string(index + 1) <> ". " <> string.inspect(node) <> "\n" } /// Format a list of Nodes pub fn format_nodes(nodes: List(Node)) -> String { "Parsed " <> int.to_string(list.length(nodes)) <> " node(s):\n" <> { nodes |> list.index_map(fn(n, idx) { format_node(n, idx) }) |> string.join("") } } /// Create a snapshot of lexer output showing input and tokens pub fn snap_lexer_output( input: String, tokens: List(Token(a)), title: String, ) -> Nil { let snap = "Input: " <> escape_string(input) <> "\n\n" <> format_tokens(input, tokens) birdie.snap(snap, title: title) } /// Create a snapshot of a parse result (success or error) pub fn snap_parse_result_nodes( input: String, result: Result(List(Node), List(nibble.DeadEnd(tok, ctx))), title: String, ) -> Nil { let snap = case result { Ok(nodes) -> "Input: " <> escape_string(input) <> "\n" <> format_nodes(nodes) Error(errors) -> // Input is shown with each error, so no need to show it at the top level format_dead_ends(input, errors) } birdie.snap(snap, title: title) } /// Create a snapshot of a parse error showing input and errors pub fn snap_parse_error( input: String, errors: List(nibble.DeadEnd(tok, ctx)), title: String, ) -> Nil { snap_parse_result_nodes(input, Error(errors), title) } /// Create a snapshot of successful parse showing input and result pub fn snap_parse_success( input: String, nodes: List(Node), title: String, ) -> Nil { snap_parse_result_nodes(input, Ok(nodes), title) } /// Create a snapshot for a Result type (success or error) pub fn snap_parse_result( input: String, result: Result(a, List(nibble.DeadEnd(tok, ctx))), title: String, format_success: fn(a) -> String, ) -> Nil { let snap = case result { Ok(value) -> { "Input: " <> escape_string(input) <> "\n\nSuccess:\n" <> format_success(value) } // Input is shown with each error, so no need to show it at the top level Error(errors) -> format_dead_ends(input, errors) } birdie.snap(snap, title: title) } /// Escape special characters in strings for readable snapshots fn escape_string(s: String) -> String { s |> string.replace("\\", "\\\\") |> string.replace("\n", "\\n") |> string.replace("\r", "\\r") |> string.replace("\t", "\\t") } // ============================================================================ // Visual Span Rendering Functions // ============================================================================ /// Split input into indexed lines (1-based) fn split_into_lines(input: String) -> List(#(Int, String)) { input |> string.split("\n") |> list.index_map(fn(line, idx) { #(idx + 1, line) }) } /// Calculate the width needed for the gutter (line numbers) fn calculate_gutter_width(max_line_num: Int) -> Int { int.to_string(max_line_num) |> string.length() } /// Render a line with its gutter (line number and separator) fn render_line_with_gutter( line_num: Int, content: String, gutter_width: Int, ) -> String { let line_str = int.to_string(line_num) let padding = string.repeat(" ", gutter_width - string.length(line_str)) padding <> line_str <> " │ " <> make_visible(content) } /// Render a continuation marker in the gutter fn render_continuation_gutter(gutter_width: Int) -> String { string.repeat(" ", gutter_width) <> " │" } /// Make invisible characters visible by escaping them /// This ensures users can see what they're working with in visual spans fn make_visible(s: String) -> String { s |> string.replace("\\", "\\\\") |> string.replace("\r", "\\r") |> string.replace("\n", "\\n") |> string.replace("\t", "\\t") } /// Calculate visible column position accounting for escaped characters /// Maps original column position to position in the escaped/visible string fn calculate_visual_column(line_content: String, original_col: Int) -> Int { line_content |> string.to_graphemes() |> list.take(original_col - 1) |> list.fold(0, fn(acc, char) { case char { "\\" -> acc + 2 // Displayed as \\ "\r" -> acc + 2 // Displayed as \r "\n" -> acc + 2 // Displayed as \n "\t" -> acc + 2 // Displayed as \t _ -> acc + 1 } }) |> fn(pos) { pos + 1 } // Add 1 because columns are 1-indexed } /// Generate a marker line with box-drawing characters /// Returns a string like " ───┬───" for a span fn visual_span_marker( gutter_width: Int, start_col: Int, end_col: Int, ) -> String { let gutter = string.repeat(" ", gutter_width + 3) let before = string.repeat(" ", start_col - 1) let marker_width = end_col - start_col case marker_width { 0 -> gutter <> before <> "┬" 1 -> gutter <> before <> "┬" _ -> { let half = marker_width / 2 let left = string.repeat("─", half) let right = string.repeat("─", marker_width - half - 1) gutter <> before <> left <> "┬" <> right } } } /// Generate a label line with box-drawing characters /// Returns a string like " └─ label text" fn visual_span_label(gutter_width: Int, col: Int, label: String) -> String { let gutter = string.repeat(" ", gutter_width + 3) let before = string.repeat(" ", col - 1) gutter <> before <> "└─ " <> label } /// Render a single span visually with context lines pub fn visual_single_span( input: String, span: Span, label: String, context_lines: Int, ) -> String { let Span(row_start, col_start, row_end, col_end) = span let lines = split_into_lines(input) let max_line = list.length(lines) let gutter_width = calculate_gutter_width(max_line) // Determine which lines to show let first_line = int.max(1, row_start - context_lines) let last_line = int.min(max_line, row_end + context_lines) // Build the output let content_lines = lines |> list.filter(fn(line) { let #(num, _) = line num >= first_line && num <= last_line }) |> list.flat_map(fn(line) { let #(num, content) = line let line_str = render_line_with_gutter(num, content, gutter_width) // Add markers for lines within the span case num >= row_start && num <= row_end { True -> { case num == row_start, num == row_end { // Single line span True, True -> { let visual_start = calculate_visual_column(content, col_start) let visual_end = calculate_visual_column(content, col_end) let marker = visual_span_marker(gutter_width, visual_start, visual_end) let visual_center = visual_start + { visual_end - visual_start } / 2 let label_line = visual_span_label(gutter_width, visual_center, label) [line_str, marker, label_line] } // First line of multi-line span True, False -> { let visual_start = calculate_visual_column(content, col_start) let visible_len = string.length(make_visible(content)) let marker = visual_span_marker(gutter_width, visual_start, visible_len + 1) [line_str, render_continuation_gutter(gutter_width) <> " " <> marker] } // Last line of multi-line span False, True -> { let visual_end = calculate_visual_column(content, col_end) let marker = visual_span_marker(gutter_width, 1, visual_end) let label_line = visual_span_label(gutter_width, visual_end / 2, label) [line_str, marker, label_line] } // Middle line of multi-line span False, False -> { let visible_len = string.length(make_visible(content)) let marker = visual_span_marker(gutter_width, 1, visible_len + 1) [line_str, render_continuation_gutter(gutter_width) <> " " <> marker] } } } False -> [line_str] } }) string.join(content_lines, "\n") } /// Render multiple spans visually (stacked vertically for same-line spans) pub fn visual_multiple_spans( input: String, spans: List(#(Span, String)), context_lines: Int, ) -> String { case spans { [] -> "" [single] -> { let #(span, label) = single visual_single_span(input, span, label, context_lines) } _ -> { // For now, render each span separately and join with blank lines spans |> list.map(fn(span_label) { let #(span, label) = span_label visual_single_span(input, span, label, context_lines) }) |> string.join("\n\n") } } }