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}