Skip to main content

noyalib/
ariadne_adapter.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! [`ariadne`] adapter for [`crate::Error`].
5//!
6//! Renders a noyalib error as an [`ariadne::Report`] with the
7//! offending byte range labelled, the line/column annotated, and
8//! the surrounding source available for terminal-friendly display.
9//! Pairs with the existing [`miette::Diagnostic`] impl for users
10//! who prefer ariadne's rendering.
11//!
12//! # Examples
13//!
14//! ```
15//! use noyalib::{from_str, Value};
16//! use noyalib::ariadne_adapter::error_to_ariadne_report;
17//! use ariadne::Source;
18//!
19//! let source = "a: [unclosed";
20//! let err = from_str::<Value>(source).unwrap_err();
21//! let report = error_to_ariadne_report(&err, "input.yaml", source);
22//! let mut buf: Vec<u8> = Vec::new();
23//! report.write(("input.yaml", Source::from(source)), &mut buf).unwrap();
24//! assert!(!buf.is_empty());
25//! ```
26
27use crate::diagnostic::{DiagnosticSeverity, SourceSpan};
28use crate::error::Error;
29use ariadne::{Color, Label, Report, ReportKind};
30
31/// Convert a noyalib [`Error`] into an [`ariadne::Report`].
32///
33/// `filename` is the source identifier ariadne prints in the
34/// report header. `source` is the YAML input — its byte length is
35/// used to clamp the highlighted byte range so a stale or trimmed
36/// `source` never panics inside ariadne.
37///
38/// When the structured diagnostic has no source labels, the resulting report
39/// is a header-only message without a source label, matching the existing
40/// [`Error::format_with_source`] fallback.
41///
42/// # Examples
43///
44/// ```
45/// use noyalib::{from_str, Value};
46/// use noyalib::ariadne_adapter::error_to_ariadne_report;
47/// use ariadne::Source;
48///
49/// let source = "a: [unclosed";
50/// let err = from_str::<Value>(source).unwrap_err();
51/// let report = error_to_ariadne_report(&err, "input.yaml", source);
52/// let mut out: Vec<u8> = Vec::new();
53/// report.write(("input.yaml", Source::from(source)), &mut out).unwrap();
54/// assert!(!out.is_empty());
55/// ```
56#[must_use]
57pub fn error_to_ariadne_report<'a>(
58    err: &Error,
59    filename: &'a str,
60    source: &str,
61) -> Report<'a, (&'a str, core::ops::Range<usize>)> {
62    let diagnostic = err.diagnostic();
63    let kind = match diagnostic.severity() {
64        DiagnosticSeverity::Error => ReportKind::Error,
65        DiagnosticSeverity::Warning => ReportKind::Warning,
66        DiagnosticSeverity::Information | DiagnosticSeverity::Hint => ReportKind::Advice,
67    };
68    let mut builder = Report::build(kind, (filename, 0..source.len()))
69        .with_code(diagnostic.code())
70        .with_message(diagnostic.message());
71
72    for diagnostic_label in diagnostic.labels() {
73        let span = label_span(diagnostic_label.span(), source);
74        let color = if diagnostic_label.is_primary() {
75            Color::Red
76        } else {
77            Color::Yellow
78        };
79        builder = builder.with_label(
80            Label::new((filename, span))
81                .with_message(diagnostic_label.message())
82                .with_color(color),
83        );
84    }
85
86    builder.finish()
87}
88
89/// Compute a sensible byte range for the error's primary label.
90///
91/// [`Location`] carries a single byte index; ariadne wants a
92/// `Range<usize>`. We expand the index to cover the next character
93/// (so the caret doesn't render as a zero-width range) and clamp
94/// to the source bounds — never panics on a trimmed `source`.
95fn label_span(span: SourceSpan, source: &str) -> core::ops::Range<usize> {
96    let mut start = span.offset().min(source.len());
97    while start > 0 && !source.is_char_boundary(start) {
98        start -= 1;
99    }
100    if start >= source.len() {
101        return start..start;
102    }
103
104    let mut end = span.end().min(source.len());
105    if end <= start {
106        end = source[start..]
107            .chars()
108            .next()
109            .map_or(start + 1, |character| start + character.len_utf8());
110    }
111    while end < source.len() && !source.is_char_boundary(end) {
112        end += 1;
113    }
114    start..end
115}