//// Error handling utilities with support for error kinds, context, and sources. import glam/doc import gleam import gleam/int import gleam/list import gleam/result import gleam/string /// A generic error type that supports error kinds, context layers, and source errors. /// /// The `kind` parameter allows you to specify custom error variants for type-safe /// error handling. Use `Error` (which is `KindError(String)`) for simple ad-hoc errors. /// /// The source is stored as a lazy function to avoid unnecessary string construction /// when the error is not displayed. pub opaque type KindError(kind) { KindError( kind: Kind(kind), context: List(fn() -> String), sources: List(Format), ) } pub type Kind(kind) { Kind(kind: kind, format: Format) } pub fn to_kind(kind: kind) -> Kind(kind) { Kind(kind: kind, format: inspect_format(kind)) } /// Controls how much detail a `Format` function should produce. pub type Detail { /// The expanded, hierarchical rendering (see `pretty_print`). Context and /// sources are broken out onto their own lines, roughly: /// /// ```text /// error: Not found /// context: /// 0: fetching user /// sources: /// ╰─▶ HTTP 404 /// ``` High /// The compact, single-line rendering (see `to_string`). Context and /// sources are folded into one line, roughly: /// /// ```text /// Not found [fetching user] (source: HTTP 404) /// ``` Low } /// The arguments passed to a `Format` function when it is rendered. /// /// Carries the requested `detail` level, which a `Format` inspects to decide /// how much to render. pub type FormatArgs { FormatArgs(detail: Detail) } /// A lazy, detail-aware renderer for a value such as an error kind or source. /// /// A `Format` is a function from `FormatArgs` to a `String`. It is kept lazy /// so that the string is only built when the error is actually displayed, and /// it receives the requested `Detail` level so the same value can render /// compactly (`Low`) or with full hierarchy (`High`). /// /// Construct one with `string_format` (a fixed string), `inspect_format` /// (via `string.inspect`), or `as_format` (render another `KindError`, which /// is how nested errors are embedded as sources). You can also write your own: /// /// ```gleam /// import glerror as error /// /// let format: error.Format = fn(args) { /// case args.detail { /// error.High -> "a long, detailed message" /// error.Low -> "short message" /// } /// } /// ``` pub type Format = fn(FormatArgs) -> String /// Formats a term using string.inspect. pub fn inspect_format(term: anything) -> Format { fn(_) -> String { string.inspect(term) } } pub fn string_format(s: String) -> Format { fn(_) { s } } pub fn as_format(error: KindError(kind)) -> Format { fn(args: FormatArgs) -> String { case args.detail { High -> pretty_print(error) Low -> to_string(error) } } } /// A convenient error type for simple string-based errors. /// /// This is the recommended type for most use cases where you don't need /// to pattern match on specific error variants. pub type Error = KindError(String) /// A convenient Result type alias for simple errors. /// /// Use this when you want `Result(value, Error)` without repeating the error type. pub type Result(value) = gleam.Result(value, Error) // ---- Construction ---- /// Create a new error from a custom error kind. /// /// ## Example /// /// ```gleam /// import glerror as error /// /// pub type MyError { /// NotFound /// InvalidInput /// } /// /// let err = error.new(error.to_kind(NotFound)) /// ``` pub fn new(kind: Kind(kind)) -> KindError(kind) { KindError(kind: kind, context: [], sources: []) } /// Create a simple string-based error. /// /// ## Example /// /// ```gleam /// import glerror as error /// let err = error.error("File not found") /// ``` pub fn error(message: String) -> Error { new(Kind(message, string_format(message))) } // ---- Modification ---- /// Add a context layer to an error. /// /// Context is added in a stack, with the most recent context appearing first /// when the error is displayed. /// /// ## Example /// /// ```gleam /// import glerror as error /// error.error("connection refused") /// |> error.add_context("connecting to database") /// |> error.add_context("initializing application") /// ``` pub fn add_context(err: KindError(kind), context: String) -> KindError(kind) { KindError(..err, context: [fn() { context }, ..err.context]) } pub fn add_context_fn( err: KindError(kind), context: fn() -> String, ) -> KindError(kind) { KindError(..err, context: [context, ..err.context]) } /// Attach a source error description. /// /// This is useful when you want to include information about the underlying /// cause of the error without exposing the actual error type. /// /// The source is stored as a lazy function and only evaluated when the error /// is displayed, avoiding unnecessary string construction. /// /// ## Example /// /// ```gleam /// import glerror as error /// error.error("Database query failed") /// |> error.with_source(fn() { "Connection timeout after 30s" }) /// /// // With dynamic data /// error.error("Database query failed") /// |> error.with_source(fn() { /// "Connection timeout at " <> format_timestamp(now()) /// }) /// ``` pub fn with_source(err: KindError(kind), source: Format) -> KindError(kind) { KindError(..err, sources: list.append(err.sources, [source])) } /// Transform the error kind using a mapping function. /// /// This is useful when converting between different error types. /// /// ## Example /// /// ```gleam /// import glerror as error /// /// pub type HttpError { /// NotFound /// ServerError /// } /// /// pub type AppError { /// HttpFailed(HttpError) /// ParseFailed /// } /// /// let http_err = error.new(error.to_kind(NotFound)) /// let app_err = error.map_kind(http_err, fn(e) { error.to_kind(HttpFailed(e)) }) /// ``` pub fn map_kind( err: KindError(kind), mapper: fn(kind) -> Kind(new_kind), ) -> KindError(new_kind) { KindError( kind: mapper(err.kind.kind), context: err.context, sources: err.sources, ) } // ---- Result Helpers ---- /// Add context to a Result if it's an error. /// /// This is the primary way to add context when working with Result types. /// The Ok value passes through unchanged. /// /// ## Example /// /// ```gleam /// parse_json(data) /// |> glerror.context("parsing user data") /// |> glerror.context("loading profile") /// ``` pub fn context( res: gleam.Result(value, KindError(kind)), context: String, ) -> gleam.Result(value, KindError(kind)) { result.map_error(res, add_context(_, context)) } /// Add context to a Result using a lazy function. /// /// The function is only called if the Result is an Error, avoiding /// unnecessary string construction in the success case. /// /// ## Example /// /// ```gleam /// import glerror as error /// /// fetch_user(user_id) /// |> error.lazy_context(fn() { /// "fetching user " <> int.to_string(user_id) /// }) /// ``` pub fn lazy_context( res: gleam.Result(value, KindError(kind)), context: fn() -> String, ) -> gleam.Result(value, KindError(kind)) { case res { Ok(_) -> res Error(err) -> Error(add_context(err, context())) } } /// Convert a Result with a different error type to use KindError. /// /// This is useful when integrating with libraries that use their own error types. /// /// ## Example /// /// ```gleam /// int.parse("42") /// |> error.from_result( /// Kind("Invalid integer", fn(_) { "Invalid integer" }), /// fn(_) { fn(_) { "Parse error" } } /// ) /// ``` pub fn from_result( res: gleam.Result(value, original_error), kind: Kind(kind), format_source: fn(original_error) -> Format, ) -> gleam.Result(value, KindError(kind)) { case res { Ok(value) -> Ok(value) Error(err) -> Error( new(kind) |> with_source(format_source(err)), ) } } // ---- Accessors ---- /// Get the error kind from a KindError. /// /// ## Example /// /// ```gleam /// import glerror as error /// /// let err = error.new(error.to_kind(NotFound)) /// error.kind(err) // NotFound /// ``` pub fn kind(err: KindError(kind)) -> kind { err.kind.kind } // ---- Display ---- /// Convert a KindError to a single-line string representation. /// /// ## Example /// /// ```gleam /// import glerror as error /// /// error.error("Database connection failed") /// |> error.add_context("loading user") /// |> error.add_context("fetching profile") /// |> error.to_string /// // "Database connection failed [loading user, fetching profile]" /// ``` pub fn to_string(err: KindError(kind)) -> String { let base = err.kind.format(FormatArgs(detail: Low)) let with_context = case err.context { [] -> base context -> { let context_str = context |> list.reverse |> list.map(fn(c) { c() }) |> string.join(", ") base <> " [" <> context_str <> "]" } } case err.sources { [] -> with_context sources -> { let sources_str = sources |> list.map(fn(s) { s(FormatArgs(detail: Low)) }) |> string.join(", ") let sources_title = case list.length(sources) { 1 -> "source" _ -> "sources" } with_context <> " (" <> sources_title <> ": " <> sources_str <> ")" } } } /// Format a KindError with hierarchical pretty printing. /// /// This produces a tree-like output with numbered context sections and /// box-drawing characters for sources, supporting nested errors via /// `as_format`. /// /// ## Example /// /// ```gleam /// import glerror as error /// /// error.error("Not found") /// |> error.add_context("fetching user") /// |> error.add_context("loading dashboard") /// |> error.with_source(fn(_) { "HTTP 404" }) /// |> error.pretty_print /// ``` /// /// Output: /// ``` /// error: Not found /// context: /// 0: fetching user /// 1: loading dashboard /// sources: /// ╰─▶ HTTP 404 /// ``` pub fn pretty_print(err: KindError(kind)) -> String { let root_doc = doc.from_string("error: " <> err.kind.format(FormatArgs(detail: High))) let parts = [root_doc] let parts = case err.context { [] -> parts context -> { let lines = list.index_map(context, fn(c, i) { doc.from_string(" " <> int.to_string(i) <> ": " <> c()) }) |> doc.join(with: doc.line) let context_doc = [doc.from_string("context:"), lines] |> doc.join(with: doc.line) |> doc.force_break list.append(parts, [context_doc]) } } let parts = case err.sources { [] -> parts sources -> { let source_docs = case sources { [] -> [] [single] -> [source_to_doc(single, True)] [first, ..rest] -> { let rest_docs = list.map(rest, fn(s) { source_to_doc(s, True) }) [source_to_doc(first, False), ..rest_docs] } } let sources_doc = [doc.from_string("sources:"), ..source_docs] |> doc.join(with: doc.line) |> doc.force_break list.append(parts, [sources_doc]) } } parts |> doc.join(with: doc.line) |> doc.force_break |> doc.to_string(80) } fn source_to_doc(source: Format, is_last: Bool) -> doc.Document { let connector = case is_last { True -> "╰─▶ " False -> "├─▶ " } let cont_prefix = case is_last { True -> " " False -> "│ " } case string.split(source(FormatArgs(detail: High)), "\n") { [] -> doc.from_string(connector) [first, ..rest] -> { let docs = [ doc.from_string(connector <> first), ..list.map(rest, fn(line) { doc.from_string(cont_prefix <> line) }) ] docs |> doc.join(with: doc.line) |> doc.force_break } } }