Packages

Create informative & beautiful source code reports.

Current section

Files

Jump to
report src report.gleam
Raw

src/report.gleam

import gleam/bool
import gleam/int
import gleam/iterator
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/order
import gleam/pair
import gleam/queue.{type Queue}
import gleam/result
import gleam/string
import gleam_community/ansi
import string_width
// TYPES -----------------------------------------------------------------------
/// Create a report with the [`error`](#error), [`warning`](#warning), and
/// [`info`](#info) functions.
pub opaque type Report {
Report(
file: String,
source: String,
message: String,
level: Level,
info: List(Info),
labels: List(Label),
)
}
type Level {
Err
Warning
Info
}
/// Some additional information attached to the end of a report.
pub type Info {
/// Results in the text passed in without any prefix. This is an improvement
/// over simply appending text to the generated report since the message will
/// be wrapped nicely on multiple lines as needed.
Text(message: String)
/// Results in `<prefix>: <message>`
Note(prefix: String, message: String)
/// Create a formatted group of rows of text. The divider string is inserted
/// between each row *header* and *content*.
///
/// ```
/// Rows(
/// rows: [
/// #("expected type", "SomeType"),
/// #("found type", "SomeOtherType"),
/// ],
/// divider: " = ",
/// )
/// ```
///
/// Rendered as (also would have additional styling):
///
/// ```text
/// expected type = SomeType
/// found type = SomeOtherType
/// ```
Rows(rows: List(#(String, String)), divider: String)
}
type Position {
Position(line: Int, column: Int)
}
type Span {
Span(from: Position, to: Position)
}
type LabelStyle {
Primary
Secondary
Context
}
/// Create a label with functions ending in `_label`.
pub opaque type Label {
Label(style: LabelStyle, message: String, pos: Span)
}
// CONSTRUCTORS ----------------------------------------------------------------
/// Create an error report.
///
/// Parameters:
/// - `file`: The name or path of the source file.
/// - `source`: The source code of the file.
/// - `message`: A short message describing the report, such as `Type mismatch`.
/// - `labels`: A list of labels to annotate the source code. If multiple labels
/// in the list overlap, the first in the list will take precedence.
/// - `info`: A list of [`Info`](#Info) messages to add to the end of the report.
pub fn error(
file file: String,
source source: String,
message message: String,
labels labels: List(Label),
info info: List(Info),
) {
Report(file:, source:, message:, level: Err, info:, labels:)
}
/// Create a warning report.
///
/// Create an error report.
///
/// Parameters:
/// - `file`: The name or path of the source file.
/// - `source`: The source code of the file.
/// - `message`: A short message describing the report, such as `Unused function`.
/// - `labels`: A list of labels to annotate the source code. If multiple labels
/// in the list overlap, the first in the list will take precedence.
/// - `info`: A list of [`Info`](#Info) messages to add to the end of the report.
pub fn warning(
file file: String,
source source: String,
message message: String,
labels labels: List(Label),
info info: List(Info),
) {
Report(file:, source:, message:, level: Warning, info:, labels:)
}
/// Create an informational report.
///
/// Parameters:
/// - `file`: The name or path of the source file.
/// - `source`: The source code of the file.
/// - `message`: A short message describing the report.
/// - `labels`: A list of labels to annotate the source code. If multiple labels
/// in the list overlap, the first in the list will take precedence.
/// - `info`: A list of [`Info`](#Info) messages to add to the end of the report.
pub fn info(
file file: String,
source source: String,
message message: String,
labels labels: List(Label),
info info: List(Info),
) {
Report(file:, source:, message:, level: Info, info:, labels:)
}
fn span(from: #(Int, Int), to: #(Int, Int)) -> Span {
Span(
from: Position(line: from.0, column: from.1),
to: Position(line: to.0, column: to.1),
)
}
/// Create a *primary* label. Primary labels represent the main reason for the
/// report (though multiple can be included as needed). The first primary label
/// in the list of labels will have its location included in the report's header.
pub fn primary_label(
message message: String,
from start: #(Int, Int),
to end: #(Int, Int),
) {
Label(style: Primary, message:, pos: span(start, end))
}
/// Create a *secondary* label. Secondary labels support the reason for the
/// report by giving additional information and context.
pub fn secondary_label(
message message: String,
from start: #(Int, Int),
to end: #(Int, Int),
) {
Label(style: Secondary, message:, pos: span(start, end))
}
/// Create a *context* label. Context labels provide locational context to the
/// report, normally by pointing out landmarks in the source code such as a
/// surrounding list or function. This can be especially useful when reporting
/// parsing errors:
///
/// ```text
/// foo([1, 2 3])
/// ┬ ━ I wanted a comma
/// ╰╴ in this list
/// ```
pub fn context_label(
message message: String,
from start: #(Int, Int),
to end: #(Int, Int),
) {
Label(style: Context, message:, pos: span(start, end))
}
// RENDERING -------------------------------------------------------------------
/// Transform a [`Report`](#Report) into a string, ready to be presented to the
/// end user. Styling is optional but recommended for most use cases.
///
/// ```
/// report.error(...)
/// |> report.to_string(style: True)
/// //-> "Error: ..."
/// ```
pub fn to_string(report: Report, style has_style: Bool) -> String {
let ctx = init_ctx(report, has_style)
let header = render_header(report, ctx)
let info = report.info |> list.map(render_info(_, ctx)) |> string.join("\n\n")
// If there are no labels, just return the header and information.
use <- bool.lazy_guard(when: report.labels == [], return: fn() {
header
<> case report.info {
[] -> ""
_ -> "\n" <> info
}
})
let lines =
ctx.relevant_lines
|> list.flat_map(render_line(_, ctx))
|> string.join("\n")
header
<> "\n"
<> full_line("┌", ctx)
<> "\n"
<> lines
<> "\n"
<> full_line("└", ctx)
<> case report.info {
[] -> ""
_ -> "\n" <> info
}
}
fn render_header(report: Report, ctx: Ctx) -> String {
let level = case report.level {
Err -> "error:"
Warning -> "warning:"
Info -> "info:"
}
let in = "in:"
let message =
report.message
|> wrap_with_prefix(ctx.color(level), at: ctx.max_width)
|> ctx.bold
let padding = string.repeat(" ", string.length(level) - string.length(in))
let span = case ctx.primary_label {
None -> ""
Some(label) ->
ctx.dim(
"@"
<> pos_to_string(label.pos.from)
<> "-"
<> pos_to_string(label.pos.to),
)
}
let location =
{ report.file <> span }
|> wrap_with_prefix(padding <> ctx.color(in), at: ctx.max_width)
message <> "\n" <> location
}
/// Render a line of source code with labels.
///
/// ```text
/// 2 │ foo(42)
/// │ ┬╌╌ ━━ this is the wrong type
/// │ ╰╴ in this function call
/// ```
fn render_line(line: Line, ctx: Ctx) -> List(String) {
case line {
Break(_) -> [gutter(number: None, line: "·", ctx:) |> ctx.dim]
Line(number:, source:) -> {
let relevant = find_relevant_labels(number, ctx)
let inner_gutter = inner_gutter(relevant.multi_labels, on: number, ctx:)
let source_line = {
let gutter =
gutter(
number: Some(int.to_string(number) |> ctx.dim),
line: "│",
ctx:,
)
let highlighted = highlight_source(source, number, ctx)
gutter <> inner_gutter <> highlighted
}
let underline =
render_single_labels(relevant.single_labels, ctx)
|> list.map(fn(underline) {
let gutter = gutter(number: None, line: "│", ctx:)
gutter <> inner_gutter <> underline
})
let multi_starts =
render_multi_label_starts(
relevant.multi_starts,
relevant.without_starts,
ctx:,
)
|> list.map(fn(contents) {
gutter(number: None, line: "│", ctx:) <> contents
})
let multi_ends =
render_multi_label_ends(
relevant.multi_ends,
relevant.without_ends,
ctx:,
)
|> list.map(fn(contents) {
gutter(number: None, line: "│", ctx:) <> contents
})
[source_line, ..list.concat([underline, multi_starts, multi_ends])]
}
}
}
/// Relevant labels are the labels that are relevant for a certain line, i.e.
/// the label's span contains the line number at some point. Use
/// `find_relevant_labels` to find the relevant labels for a given line.
type RelevantLabels {
RelevantLabels(
/// Labels that don't span across multiple lines.
single_labels: List(Label),
/// Labels that DO span across multiple lines.
multi_labels: List(#(Int, Label)),
/// Multi-line labels that start on the given line.
multi_starts: List(#(Int, Label)),
/// Multi-line labels that end on the given line.
multi_ends: List(#(Int, Label)),
/// Relevant multi-line labels that don't start on the given line.
without_starts: List(#(Int, Label)),
/// Relevant multi-line labels that don't end on the given line.
without_ends: List(#(Int, Label)),
)
}
/// Find the relevant labels for a given line number.
fn find_relevant_labels(line: Int, ctx: Ctx) -> RelevantLabels {
let single_labels =
list.filter(ctx.single_labels, fn(label) { line == label.pos.from.line })
let #(multi_labels, multi_starts, multi_ends, without_starts, without_ends) = {
let add_if = fn(acc, predicate, label) {
case predicate {
True -> [label, ..acc]
False -> acc
}
}
use acc, tagged <- list.fold(ctx.multi_labels, #([], [], [], [], []))
let #(_, label) = tagged
let #(labels, starts, ends, without_starts, without_ends) = acc
let is_relevant = line >= label.pos.from.line && line <= label.pos.to.line
let is_start = line == label.pos.from.line
let is_end = line == label.pos.to.line
#(
add_if(labels, is_relevant, tagged),
add_if(starts, is_start, tagged),
add_if(ends, is_end, tagged),
add_if(without_starts, is_relevant && !is_start, tagged),
add_if(without_ends, is_relevant && !is_end, tagged),
)
}
RelevantLabels(
single_labels:,
multi_labels:,
multi_starts:,
multi_ends:,
without_starts:,
without_ends:,
)
}
/// Highlight a line of source code based on the primary label's span and color.
fn highlight_source(source: String, line: Int, ctx: Ctx) -> String {
case ctx.primary_label {
Some(label) if line >= label.pos.from.line && line <= label.pos.to.line -> {
let length = string.length(source)
let from = case line == label.pos.from.line {
True -> label.pos.from.column - 1
False -> 0
}
let to = case line == label.pos.to.line {
True -> label.pos.to.column - 1
False -> length
}
let left = string.slice(source, at_index: 0, length: from)
let middle = string.slice(source, at_index: from, length: to - from)
let right = string.slice(source, at_index: to, length: length - to)
left <> ctx.color(middle) <> right
}
_ -> source
}
}
/// Given a list of labels, render them into helpful underlines and messages.
///
/// ```text
/// ┬╌╌ ━━ this is the wrong type
/// ╰╴ in this function call
/// ```
fn render_single_labels(labels: List(Label), ctx: Ctx) -> List(String) {
use <- bool.guard(return: [], when: labels == [])
// It might seem like there's a lot of list traversing going on here and in
// related functions (which there is), but in reality the list of labels will
// almost never be very long, at most 3 or 4 elements. Having lots of labels
// on a single line is very rare in practice.
// Sort the labels by their first column. Labels are sorted so that they will
// be rendered stepwise in the correct order. This is very important!
let sorted_labels =
list.sort(labels, fn(a, b) {
int.compare(a.pos.from.column, b.pos.from.column)
})
// Guaranteed to be non-empty (see guard above)
let assert [first, ..] = sorted_labels
let from = first.pos.from.column
let #(_, to, trailing_label, reversed_labels) =
list.fold(sorted_labels, #(0, 0, False, []), fn(acc, label) {
let #(from, to, _, reversed) = acc
#(
int.max(from, label.pos.from.column),
int.max(to, label.pos.to.column),
label.pos.from.column >= from && label.pos.to.column >= to,
[label, ..reversed],
)
})
let #(trailing, without_trailing) = case trailing_label, reversed_labels {
True, [trailing, ..rest] -> #(Some(trailing), list.reverse(rest))
_, _ -> #(None, list.reverse(reversed_labels))
}
let underline = build_underline(labels, from, to, trailing, ctx)
let label_queue = queue.from_list(without_trailing)
let message_lines = build_message_lines(label_queue, [], ctx)
[underline, ..message_lines]
}
/// Build an underline based on a list of labels and start/end columns.
///
/// ```text
/// ┬╌╌ ━━ this is the wrong type
/// ```
///
/// 1. Generate a range between the start column and the end column.
/// 2. Iterate through the range and choose an appropriate character and color
/// based on if the current column is part of a label or not.
/// 3. If there is a trailing underline, append its message to the underline.
fn build_underline(
labels: List(Label),
from: Int,
to: Int,
trailing: Option(Label),
ctx: Ctx,
) -> String {
let padding = string.repeat(" ", from - 1)
let underline = {
use acc, column <- list.fold(over: list.range(from, to - 1), from: "")
let found_label =
list.find(labels, fn(label) {
column >= label.pos.from.column && column < label.pos.to.column
})
case found_label {
// There is no label/underline for this column
Error(_) -> acc <> " "
Ok(label) -> {
let color = color_of(label, ctx)
let render_start_char =
column == label.pos.from.column && trailing != Some(label)
// The first column in an underline is a '┬' unless the label is trailing.
let char = case render_start_char, label.style {
True, Primary -> "┯"
False, Primary -> "━"
False, Context -> "╌"
True, _ -> "┬"
False, _ -> "─"
}
acc <> color(char)
}
}
}
let message = case trailing {
Some(label) -> " " <> color_of(label, ctx)(label.message)
None -> ""
}
padding <> underline <> message
}
/// Given a queue of labels, iterate through them and build a list of lines.
///
/// ```text
/// ╰╴ in this function call
/// ```
///
/// 1. If labels queue is empty, return the lines
/// 2. Iterate through the queue by passing it to `build_message_line` then
/// popping the *last* label off the queue, effectively reducing the amount
/// of labels by one each iteration. This is why we use a queue in the first
/// place—we need to remove elements from both the front and back.
fn build_message_lines(
label_queue: Queue(Label),
result: List(String),
ctx: Ctx,
) -> List(String) {
case queue.is_empty(label_queue) {
True -> list.reverse(result)
False -> {
let line = build_message_line("", label_queue, 0, ctx)
let assert Ok(#(_, without_last)) = queue.pop_back(label_queue)
build_message_lines(without_last, [line, ..result], ctx)
}
}
}
/// Build a line of message pointers given a queue of available labels. The last
/// label in the queue will have its message written on the far right.
///
/// ```text
/// ╰╴ in this function call
/// ```
///
/// 1. If labels queue is empty, return built string
/// 2. Iterate through the queue by popping the first label off each iteration,
/// accumulating the result.
fn build_message_line(
result: String,
labels: Queue(Label),
offset: Int,
ctx: Ctx,
) -> String {
case queue.is_empty(labels) {
True -> result
False -> {
let assert Ok(#(label, without_first)) = queue.pop_front(labels)
let padding =
string.repeat(
" ",
label.pos.from.column - offset - 1,
// The `1` accounts for the written character (`│`)
)
// We're at the end of the line if there are no more labels in the queue
let end = case queue.is_empty(without_first) {
True -> "╰╴ " <> label.message
False -> "│"
}
let result = result <> padding <> color_of(label, ctx)(end)
build_message_line(result, without_first, label.pos.from.column, ctx)
}
}
}
/// Given a number and line string, return a formatted 'gutter' based on the
/// ctx's `number_offset`.
///
/// ```
/// // given number_offset = 3
///
/// gutter(number: Some("1"), line: "|", ctx:)
/// //-> " 1 | "
///
/// gutter(number: None, line: "|", ctx:)
/// //-> " | "
/// ```
fn gutter(
number inner: Option(String),
line separator: String,
ctx ctx: Ctx,
) -> String {
let inner = case inner {
None -> string.repeat(" ", ctx.number_offset)
Some(inner) ->
string_width.align(
inner,
align: string_width.Right,
to: ctx.number_offset,
with: " ",
)
}
inner <> " " <> ctx.dim(separator) <> " "
}
/// The inner gutter continues the vertical lines of multi-line labels. It may
/// not look like much on its own, but it is very important to the overal report.
///
/// ```text
/// │ │ │
/// ```
fn inner_gutter(
labels: List(#(Int, Label)),
on line: Int,
ctx ctx: Ctx,
) -> String {
use column, filler <- build_columns(ctx.max_overlapping)
case list.key_find(labels, column) {
Error(_) -> #(string.repeat(filler, 2), filler)
Ok(label) -> {
let color = color_of(label, ctx)
let chars = case line == label.pos.from.line {
True -> string.repeat(filler, 2)
False -> "│" <> filler
}
#(color(chars), filler)
}
}
}
/// Render the starts of multi-line labels, each given its own line.
fn render_multi_label_starts(
starts: List(#(Int, Label)),
others: List(#(Int, Label)),
ctx ctx: Ctx,
) -> List(String) {
let starts_outside_in = list.sort(starts, fn(a, b) { int.compare(a.0, b.0) })
do_render_multi_label_starts(starts_outside_in, [], others, ctx)
}
fn do_render_multi_label_starts(
starts: List(#(Int, Label)),
result: List(String),
others: List(#(Int, Label)),
ctx: Ctx,
) -> List(String) {
case starts {
[] -> list.reverse(result)
[start, ..rest_starts] -> {
let rendered = render_multi_label_start(start, others, ctx)
do_render_multi_label_starts(
rest_starts,
[rendered, ..result],
// Notice how the start is added to the `others` list. This allows the
// label to be continued vertically while the rest of the starts are
// rendered line-by-line.
[start, ..others],
ctx,
)
}
}
}
fn render_multi_label_start(
start: #(Int, Label),
labels: List(#(Int, Label)),
ctx ctx: Ctx,
) -> String {
let #(start_col, start_label) = start
let start_color = color_of(start_label, ctx)
let result_start = {
use column, filler <- build_columns(ctx.max_overlapping)
case column == start_col {
True -> #(start_color("╭─"), start_color("─"))
False ->
case list.key_find(labels, column) {
Ok(label) -> {
let color = color_of(label, ctx)
#(color("│") <> filler, filler)
}
Error(_) -> #(string.repeat(filler, 2), filler)
}
}
}
let pointer = string.repeat("─", start_label.pos.from.column - 1) <> "^"
result_start <> start_color(pointer)
}
fn render_multi_label_ends(
ends: List(#(Int, Label)),
others: List(#(Int, Label)),
ctx ctx: Ctx,
) -> List(String) {
let ends_inside_out =
list.sort(ends, fn(a, b) { order.negate(int.compare(a.0, b.0)) })
do_render_multi_label_ends(ends_inside_out, [], others, ctx)
}
fn do_render_multi_label_ends(
ends: List(#(Int, Label)),
result: List(String),
others: List(#(Int, Label)),
ctx: Ctx,
) -> List(String) {
case ends {
[] -> list.reverse(result)
[end, ..rest_ends] -> {
let rendered =
render_multi_label_end(end, list.concat([others, rest_ends]), ctx)
do_render_multi_label_ends(rest_ends, [rendered, ..result], others, ctx)
}
}
}
///
///
/// ```text
/// │ ╰─│─────' this is a message
/// ```
fn render_multi_label_end(
end: #(Int, Label),
labels: List(#(Int, Label)),
ctx: Ctx,
) -> String {
let #(end_col, end_label) = end
let end_color = color_of(end_label, ctx)
let start = {
use column, filler <- build_columns(ctx.max_overlapping)
case column == end_col {
True -> #(end_color("╰─"), end_color("─"))
False ->
case list.key_find(labels, column) {
Ok(label) -> {
let color = color_of(label, ctx)
#(color("│") <> filler, filler)
}
Error(_) -> #(string.repeat(filler, 2), filler)
}
}
}
let pointer =
string.repeat("─", end_label.pos.to.column - 2)
<> "^ "
<> end_label.message
start <> end_color(pointer)
}
/// Build up a string based on a certain number of 'columns' (grid columns) with
/// a rendering function. The rendering function takes the current column number
/// and a filler character (more on this below) and returns the rendered string
/// for the column and a new filler character.
///
/// The filler character can be used to fill in the gaps between columns. This
/// begins as the space character (' ') but can change to anything else such as
/// '─', making rendering something like this possible:
///
/// ```text
/// │ ╰─│─
/// ^ ^ ^
/// 1 2 3
/// ```
///
/// Here `^` marks the start of each column. Note that columns start at 1.
fn build_columns(
columns: Int,
do: fn(Int, String) -> #(String, String),
) -> String {
use <- bool.guard(return: "", when: columns < 1)
list.fold(list.range(1, columns), #("", " "), fn(acc, column) {
let #(result, filler) = acc
let #(rendered, filler) = do(column, filler)
#(result <> rendered, filler)
})
|> pair.first
}
fn render_info(info_bit: Info, ctx: Ctx) -> String {
let at = ctx.max_width - ctx.number_offset - 1
let message = case info_bit {
Text(message) -> message |> wrap_with_prefix("", at:)
Note(prefix:, message:) ->
message |> wrap_with_prefix(ctx.bold(prefix <> ":"), at:)
Rows(rows:, divider:) -> render_rows(rows, divider, at, ctx)
}
string_width.stack_horizontal(
[string.repeat(" ", ctx.number_offset), message],
place: string_width.Top,
gap: 1,
with: " ",
)
}
fn render_rows(
rows: List(#(String, String)),
divider: String,
width: Int,
ctx: Ctx,
) -> String {
let left_width =
list.fold(rows, 0, fn(w, row) { int.max(w, string_width.line(row.0)) })
list.map(rows, fn(row) {
let #(left, right) = row
[
string_width.align(
left,
to: left_width,
align: string_width.Right,
with: " ",
)
|> ctx.bold,
ctx.dim(divider),
string_width.limit(
right,
to: string_width.Size(
rows: max_lines,
columns: width - left_width - string_width.line(divider),
),
ellipsis: "",
),
]
|> string_width.stack_horizontal(place: string_width.Top, gap: 0, with: " ")
})
|> string_width.stack_vertical(align: string_width.Left, gap: 0, with: " ")
}
fn full_line(prefix: String, ctx: Ctx) -> String {
let padding = string.repeat(" ", ctx.number_offset + 1)
ctx.dim(
padding
<> prefix
<> string.repeat("─", ctx.terminal_width - ctx.number_offset - 2),
)
}
// CONTEXT ---------------------------------------------------------------------
/// This record holds a set of computed values that are used across the renderer,
/// mainly so we don't have to pass lots of parameters around or compute things
/// multiple times.
type Ctx {
Ctx(
// Coloring functions
color: fn(String) -> String,
dim: fn(String) -> String,
bold: fn(String) -> String,
secondary_color: fn(String) -> String,
// Terminal width
terminal_width: Int,
// Max width for rendering, used for wrapping text and other things
max_width: Int,
// Lines that should be rendered
relevant_lines: List(Line),
// This number refers to the width (in characters) of the largest line number
// in the `relevant_lines`
number_offset: Int,
// Maximum number of overlapping multiline labels. This number is useful for
// determining how many spaces to pad things with.
max_overlapping: Int,
// The 'primary' label is the first `Primary` label in the report's list of
// labels, if it exists.
primary_label: Option(Label),
/// Single labels are labels that fit on a single line.
single_labels: List(Label),
/// Multi labels span multiple lines.
multi_labels: List(#(Int, Label)),
)
}
type Line {
/// Represents a break between two lines that are not consecutive, rendered
/// as an empty line with a dot.
Break(number: Int)
/// A normal line of source code.
Line(number: Int, source: String)
}
fn init_ctx(report: Report, has_style: Bool) -> Ctx {
let #(min_line, max_line, single_labels, multi_labels, primary_label) =
list.fold(report.labels, #(0, 0, [], [], None), fn(acc, label) {
let #(min, max, single_labels, multi_labels, primary_label) = acc
let min = case min {
0 -> label.pos.from.line
_ -> int.min(label.pos.from.line, min)
}
let max = int.max(max, label.pos.to.line)
let #(single_labels, multi_labels) = case
label.pos.from.line == label.pos.to.line
{
True -> #([label, ..single_labels], multi_labels)
False -> #(single_labels, [label, ..multi_labels])
}
let primary_label = case primary_label, label.style {
None, Primary -> Some(label)
_, _ -> primary_label
}
#(min, max, single_labels, multi_labels, primary_label)
})
let single_labels = list.reverse(single_labels)
let #(tagged_multi_labels, max_overlapping) = tag_multi_labels(multi_labels)
let relevant_lines =
report.source
|> string.split("\n")
|> iterator.from_list
|> iterator.index
|> iterator.filter(fn(pair) {
let line = pair.1 + 1
line >= min_line && line <= max_line
})
|> iterator.fold([], fn(lines, pair) {
let #(source, i) = pair
let line = i + 1
let has_label =
list.any(report.labels, fn(label) {
line >= label.pos.from.line && line <= label.pos.to.line
})
case has_label, lines {
// A Break is not added if there is already one directly preceeding it
False, [Break(_), ..] -> lines
False, _ -> [Break(number: line), ..lines]
True, _ -> [Line(number: line, source:), ..lines]
}
})
|> list.reverse
let number_offset =
max_line
|> int.to_string
|> string.length
let style = fn(style_fn) {
case has_style {
True -> style_fn
False -> fn(x) { x }
}
}
let color =
case report.level {
Err -> ansi.red
Warning -> ansi.yellow
Info -> ansi.blue
}
|> style
let terminal_width =
string_width.get_terminal_size()
|> result.map(fn(size) { size.columns })
|> result.unwrap(80)
let max_width = terminal_width |> int.min(80)
Ctx(
color:,
dim: style(ansi.dim),
bold: style(ansi.bold),
secondary_color: style(ansi.cyan),
terminal_width:,
max_width:,
relevant_lines:,
number_offset:,
max_overlapping:,
primary_label:,
single_labels:,
multi_labels: tagged_multi_labels,
)
}
/// 'Tag' multiline labels with a number that indicates how nested within other
/// multiline labels they are. Also returns the max amount of overlapping labels
/// at a single time.
fn tag_multi_labels(labels: List(Label)) -> #(List(#(Int, Label)), Int) {
let #(labels, max_overlapping, _, _, _) =
list.sort(labels, by: fn(a, b) {
int.compare(a.pos.from.line, b.pos.from.line)
})
|> list.fold(#([], 0, 1, 0, 0), fn(acc, label) {
let #(
labels,
max_overlapping,
overlapping_labels,
greatest_start,
greatest_end,
) = acc
let start = label.pos.from.line
let end = label.pos.to.line
let overlaps = start >= greatest_start && start <= greatest_end
let overlapping_labels = case overlaps {
True -> overlapping_labels + 1
False -> 1
}
let labels = [#(overlapping_labels, label), ..labels]
#(
labels,
int.max(overlapping_labels, max_overlapping),
overlapping_labels,
start,
int.max(end, greatest_end),
)
})
#(labels, max_overlapping)
}
// HELPERS ---------------------------------------------------------------------
// fn count_start_spaces(text: String) -> Int {
// do_count_start_spaces(text, 0)
// }
// fn do_count_start_spaces(text: String, count: Int) -> Int {
// case text {
// " " <> rest -> do_count_start_spaces(rest, count + 1)
// "\t" <> rest -> do_count_start_spaces(rest, count + 1)
// _ -> count
// }
// }
fn pos_to_string(pos: Position) -> String {
let line = pos.line |> int.to_string
let column = pos.column |> int.to_string
line <> ":" <> column
}
fn color_of(label: Label, ctx: Ctx) -> fn(String) -> String {
case label.style {
Primary -> ctx.color
Secondary -> ctx.secondary_color
Context -> ctx.dim
}
}
const max_lines = 999_999
fn wrap_with_prefix(text: String, prefix: String, at width: Int) -> String {
let prefix_length = string_width.line(prefix)
let gap = case prefix_length {
0 -> 0
_ -> 1
}
[
prefix,
text
|> string_width.limit(
string_width.Size(rows: max_lines, columns: width - prefix_length - gap),
ellipsis: "",
),
]
|> string_width.stack_horizontal(place: string_width.Top, gap:, with: " ")
}