error-report

    Source-annotated diagnostic reports: data first, renderers second. Inspired by miette, codespan-reporting and ariadne.

    diagnostics
    errors
    error-reporting
    source-code
    compiler
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    22 hours ago
    Downloads
    24

    #error-report

    Source-annotated diagnostic reports for MoonBit, in the lineage of Rust's miette, codespan-reporting and ariadne.

    Data first, renderers second. A Report is a plain value: severity, a stable code, a message, labelled spans, notes, a help line, and machine-applicable fixes. A consumer that wants to build its own presentation reads the fields and never calls a renderer. A consumer that just wants something good to print in a terminal calls render and is done.

    #Installing

    moon add marianoguerra/error-report

    The module name contains a dash, so it cannot be referenced under its default alias. Give it one in the moon.pkg of each package that uses it:

    import { "marianoguerra/error-report" @report, "marianoguerra/error-report/render", }

    #A report

    ///|
    test {
    let sources = @report.Sources::new()
    let id = sources.add("greeting.txt", "hello:\n world\n universe\n")
    let r = @report.Report::error("wrong indentation")
    .with_code("demo::wrong_indentation")
    .with_label(
    @report.Label::primary(
    id,
    @report.Span::of_range(9, 14),
    message="this group starts here",
    ),
    )
    .with_label(
    @report.Label::secondary(
    id,
    @report.Span::of_range(16, 24),
    message="but this one is less indented",
    ),
    )
    .with_help("indent every group in a block to the same column")
    let config = {
    ..@render.default_config,
    theme: @style.mono_theme,
    color: Never,
    }
    inspect(
    @render.render_string(r, sources, config),
    content=(
    #|error[demo::wrong_indentation]: wrong indentation
    #| ╭─[ greeting.txt:2:3 ]
    #| │
    #|1 │ hello:
    #|2 │ world
    #| │ ──┬──
    #| │ ╰─ this group starts here
    #|3 │ universe
    #| │ ───┬────
    #| │ ╰─ but this one is less indented
    #| │
    #| ├─ help: indent every group in a block to the same column
    #| ╰─
    #|
    ),
    )
    }

    #A span that crosses lines

    An underline cannot say "from here to there" across a line break without lying about the columns in between, so a multi-line label is drawn as a bracket in a left margin instead.

    ///|
    test {
    let sources = @report.Sources::new()
    let id = sources.add("call.txt", "outer(\n a,\n b\n")
    let r = @report.Report::error("unclosed delimiter").with_label(
    @report.Label::primary(
    id,
    @report.Span::of_range(0, 16),
    message="this is never closed",
    ),
    )
    let config = {
    ..@render.default_config,
    theme: @style.mono_theme,
    color: Never,
    }
    inspect(
    @render.render_string(r, sources, config),
    content=(
    #|error: unclosed delimiter
    #| ╭─[ call.txt:1:1 ]
    #| │
    #|1 │ ╭ outer(
    #|2 │ │ a,
    #|3 │ ├ b
    #| │ ╰─ this is never closed
    #| ╰─
    #|
    ),
    )
    }

    #Other formats

    Format::Short is one line per report, in the shape editors and grep already understand. Format::Json is one JSON object per line. Format::Compact is the headed message with no source snippet, for when the source is not to hand or a hundred reports are being listed.

    ///|
    test {
    let sources = @report.Sources::new()
    let id = sources.add("greeting.txt", "hello:\n world\n universe\n")
    let r = @report.Report::error("wrong indentation")
    .with_code("demo::wrong_indentation")
    .with_label(@report.Label::primary(id, @report.Span::of_range(16, 24)))
    let config = {
    ..@render.default_config,
    theme: @style.mono_theme,
    color: Never,
    format: Short,
    }
    inspect(
    @render.render_string(r, sources, config),
    content=(
    #|greeting.txt:3:2: error[demo::wrong_indentation]: wrong indentation
    #|
    ),
    )
    }

    #Units

    Every offset in this library is a UTF-16 code unit offset into the source String — the unit String::length, String::at and String::get_view use, so slicing carries no hidden conversion.

    That will surprise anyone coming from a library in a language whose strings are bytes. A producer counting something else converts once, at the boundary: Source::span_of_chars takes code-point offsets, which is what Racket's port-next-location and most hand-written lexers report. Converting at the boundary is cheap; converting inside the renderer, on every label of every report, is not.

    Rendered columns are a third thing again: display columns, with tabs expanded and East Asian Wide characters counted as two, so that a caret lands under the character it means.

    #Colour

    render takes is_tty as a parameter rather than detecting it. This library has no dependencies and so cannot see a file descriptor; guessing would either strip colour from a terminal or write escape codes into a redirected file, and both are worse than asking. ColorMode::Auto resolves against whatever the caller passes.

    #Extending

    SourceCache is the single extension point — a trait over (SourceId) -> Source?. Implement it to read from disk, or from an editor's unsaved buffers. Sources is the in-memory implementation and is what most callers want.

    There is deliberately nothing else to extend: no callback into the consumer, and no genericity over a consumer-supplied error type.

    #Licence

    Apache-2.0.

    SourceCache

    pub(open) trait SourceCache {
    fn fetch(Self, SourceId) -> Source?
    }

    Where a report's renderer gets its sources from.

    The single extension point of this library, and deliberately the only one: a consumer that reads from disk, or from an editor's unsaved buffers, implements this and nothing else. Sources below is the in-memory implementation, which is what most callers want.

    Fix

    pub(all) struct Fix {
    source : SourceId
    span : Span
    replacement : String
    description : String
    } derive(Eq,
    Debug
    )

    A machine-applicable repair: replace span with replacement.

    Separate from Label because it is not a thing to point at, it is a thing to do. A consumer that only renders text shows description; a consumer wiring up an editor's quick-fix applies the edit and ignores the prose. Conflating the two is what forces the second consumer to parse the first one's output.

    Label

    pub(all) struct Label {
    source : SourceId
    span : Span
    style : LabelStyle
    message : String?
    priority : Int
    } derive(Eq,
    Debug
    )

    A span of source with an optional note attached.

    Label::primary

    fn Label::primary(source : SourceId, span : Span, message? : String) -> Label

    Label::secondary

    fn Label::secondary(source : SourceId, span : Span, message? : String) -> Label

    Label::with_priority

    fn Label::with_priority(self : Label, priority : Int) -> Label

    LabelStyle

    pub(all) enum LabelStyle {
    Primary
    Secondary
    } derive(Eq, ToJson,
    Debug
    )

    Whether a label points at the thing that is wrong, or at context.

    codespan-reporting's distinction, and worth keeping: a report with three labels is unreadable unless the reader can tell which one is the defect and which two are "declared here" and "first used here".

    Report

    pub(all) struct Report {
    severity : Severity
    code : String?
    message : String
    labels : Array[Label]
    notes : Array[String]
    help : String?
    url : String?
    related : Array[Report]
    fixes : Array[Fix]
    }

    One diagnostic: what is wrong, where, and what to do about it.

    The fields are the union of what miette, codespan-reporting and ariadne each found necessary, and the split between them is the point of this type: message says what is wrong, labels say where, help says what to do, notes say what else to know, and fixes say it in a form a machine can apply. A consumer that wants none of the rendering can read all five.

    Report::advice

    fn Report::advice(message : String) -> Report

    Report::anchor

    fn Report::anchor(self : Report) -> Label?

    The label a renderer should lead with: the first Primary, or failing that the first label of any kind.

    "Failing that" rather than "nothing" because a report whose labels are all secondary is a caller mistake that should still render something useful -- refusing to show a snippet would punish the reader for the producer's bug.

    Report::error

    fn Report::error(message : String) -> Report

    Report::new

    fn Report::new(severity : Severity, message : String) -> Report

    A report with only the required parts. Everything else is added by the with_* methods, which is what keeps the common case one line.

    Report::warning

    fn Report::warning(message : String) -> Report

    Report::with_code

    fn Report::with_code(self : Report, code : String) -> Report

    Report::with_fix

    fn Report::with_fix(self : Report, fix : Fix) -> Report

    Report::with_help

    fn Report::with_help(self : Report, help : String) -> Report

    Report::with_label

    fn Report::with_label(self : Report, label : Label) -> Report

    Report::with_note

    fn Report::with_note(self : Report, note : String) -> Report

    fn Report::with_related(self : Report, related : Report) -> Report

    Report::with_url

    fn Report::with_url(self : Report, url : String) -> Report

    Severity

    pub(all) enum Severity {
    Error
    Warning
    Advice
    } derive(Compare, Eq, Hash, ToJson,
    Debug
    )

    How serious a report is.

    Advice is miette's third level: a suggestion that is neither wrong nor suspicious, which a formatter or a linter wants and a compiler mostly does not. Keeping it here rather than making consumers extend the enum is what stops every consumer inventing a fourth name for the same thing.

    Severity::name

    fn Severity::name(self : Severity) -> String

    The lowercase name used in rendered output and in JSON.

    Source

    pub struct Source {
    name : String
    text : String
    line_starts : Array[Int]
    }

    A source file, with the line index precomputed.

    Building one is O(n) in the text, so build it once and share it. Every offset in the API -- line_starts, and everything on Span -- is a UTF-16 code-unit offset into text.

    Source::line_col

    fn Source::line_col(self : Source, offset : Int) -> (Int, Int)

    1-based line and 0-based column of offset, with the column in CODE POINTS.

    The column is not the raw offset difference: a surrogate pair is one column and two code units, so a caret under an emoji would otherwise land one column late for every character after it on the line.

    Source::line_count

    fn Source::line_count(self : Source) -> Int

    Number of lines. A file with no trailing newline still counts its last line; a file that ends in a newline has a final empty line, which is what an editor shows and where an end-of-file caret has to go.

    Source::line_of

    fn Source::line_of(self : Source, offset : Int) -> Int

    The 0-based line containing offset, by binary search.

    An offset past the end clamps to the last line rather than failing: a diagnostic pointing just past the end of the file is a normal thing (an unexpected end of input), not a caller error.

    Source::line_text

    fn Source::line_text(self : Source, line : Int) -> String

    The text of a 0-based line, without its terminator.

    Source::new

    fn Source::new(name : String, text : String) -> Source

    Index text, recognising all three line terminators.

    A \r\n counts as one break, and a lone \r counts as a break too -- which is what a user staring at the file in an editor sees, and what the language specifications that still admit a bare \r require. Getting this wrong shows up as every caret after the first CRLF sitting one column to the right, which is subtle enough to ship.

    Source::offset_of_char

    fn Source::offset_of_char(self : Source, char_offset : Int) -> Int

    Convert a code-point offset into the code-unit offset this library uses.

    The boundary function for a producer that counts code points, which most hand-written lexers and every Racket port do. O(n) in the offset, so convert once per span and not once per lookup.

    Source::slice

    fn Source::slice(self : Source, span : Span) -> String

    The text a span covers.

    Source::span_of_chars

    fn Source::span_of_chars(self : Source, start : Int, end : Int) -> Span

    Build a span from code-point offsets.

    SourceId

    pub(all) struct SourceId(Int) derive(Compare, Eq, Hash, ToJson,
    Debug
    )

    Identifies one source file within a report. Opaque on purpose: it is handed out by whatever cache the consumer uses and means nothing on its own.

    Sources

    pub struct Sources {
    entries : Array[Source]
    }

    An in-memory SourceCache.

    Sources::add

    fn Sources::add(self : Sources, name : String, text : String) -> SourceId

    Add a file and get the id to put in its labels.

    Sources::new

    fn Sources::new() -> Sources

    Span

    pub(all) struct Span {
    start : Int
    len : Int
    } derive(Compare, Eq, Hash, ToJson,
    Debug
    )

    A half-open range of a source file.

    The unit is UTF-16 code units -- the same unit String::length, String::at and String::get_view use, so slicing a source is direct and carries no hidden conversion. That choice is deliberate and it is the one thing about this library that will surprise someone: most diagnostic libraries in other languages count bytes, because that is what their strings are made of. Here the strings are UTF-16, so bytes would be the converted unit rather than the native one.

    A producer that counts something else -- code points, as Racket's port-next-location does, or bytes, as a UTF-8 lexer does -- converts once, at the boundary, with Source::span_of_chars or Source::span_of_bytes. Converting at the boundary is cheap; converting inside the renderer, on every label of every report, is not.

    Span::at

    fn Span::at(offset : Int) -> Span

    An insertion point: an empty span at offset.

    Span::end

    fn Span::end(self : Span) -> Int

    Offset one past the last code unit.

    Span::is_empty

    fn Span::is_empty(self : Span) -> Bool

    Span::merge

    fn Span::merge(self : Span, other : Span) -> Span

    The smallest span covering both operands.

    Span::of_range

    fn Span::of_range(start : Int, end : Int) -> Span

    The span [start, end).

    Span::overlaps

    fn Span::overlaps(self : Span, other : Span) -> Bool

    Whether the two spans share at least one code unit.

    Two empty spans at the same offset do NOT overlap: an insertion point has no extent, so nothing can be inside it. That matters because the renderer uses this to decide which labels may share an underline row, and two insertion carets at the same column are exactly the case that should sit side by side.