Skip to main content

usage/
miette.rs

1//! Small, dependency-free diagnostics used by usage.
2//!
3//! This intentionally implements only the part of miette's API that usage exposed or used.
4//! Keeping the familiar names avoids making downstream callers translate errors merely because
5//! the renderer is now local.
6
7use std::error::Error as StdError;
8use std::fmt::{Debug, Display, Formatter};
9use unicode_width::UnicodeWidthChar;
10
11#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
12pub struct SourceOffset(usize);
13
14impl From<usize> for SourceOffset {
15    fn from(value: usize) -> Self {
16        Self(value)
17    }
18}
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub struct SourceSpan {
22    offset: SourceOffset,
23    len: usize,
24}
25
26impl SourceSpan {
27    pub fn new(offset: SourceOffset, len: usize) -> Self {
28        Self { offset, len }
29    }
30
31    pub fn offset(self) -> usize {
32        self.offset.0
33    }
34
35    pub fn len(self) -> usize {
36        self.len
37    }
38
39    pub fn is_empty(self) -> bool {
40        self.len == 0
41    }
42}
43
44impl From<(usize, usize)> for SourceSpan {
45    fn from((offset, len): (usize, usize)) -> Self {
46        Self::new(offset.into(), len)
47    }
48}
49
50impl From<std::ops::Range<usize>> for SourceSpan {
51    fn from(range: std::ops::Range<usize>) -> Self {
52        Self::new(range.start.into(), range.end.saturating_sub(range.start))
53    }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct NamedSource<S> {
58    name: String,
59    source: S,
60}
61
62impl<S> NamedSource<S> {
63    pub fn new(name: impl Display, source: S) -> Self {
64        Self {
65            name: name.to_string(),
66            source,
67        }
68    }
69
70    pub fn name(&self) -> &str {
71        &self.name
72    }
73
74    pub fn inner(&self) -> &S {
75        &self.source
76    }
77}
78
79#[cfg(feature = "miette")]
80impl ::miette::SourceCode for NamedSource<String> {
81    fn read_span<'a>(
82        &'a self,
83        span: &::miette::SourceSpan,
84        context_lines_before: usize,
85        context_lines_after: usize,
86    ) -> std::result::Result<Box<dyn ::miette::SpanContents<'a> + 'a>, ::miette::MietteError> {
87        let contents = ::miette::SourceCode::read_span(
88            &self.source,
89            span,
90            context_lines_before,
91            context_lines_after,
92        )?;
93        Ok(Box::new(::miette::MietteSpanContents::new_named(
94            self.name.clone(),
95            contents.data(),
96            *contents.span(),
97            contents.line(),
98            contents.column(),
99            contents.line_count(),
100        )))
101    }
102}
103
104#[derive(Debug)]
105pub struct MietteError(String);
106
107impl MietteError {
108    pub fn new(message: impl Into<String>) -> Self {
109        Self(message.into())
110    }
111}
112
113impl Display for MietteError {
114    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
115        f.write_str(&self.0)
116    }
117}
118
119impl StdError for MietteError {}
120
121pub struct Error(Box<dyn StdError + Send + Sync + 'static>);
122pub type Report = Error;
123pub type Result<T, E = Error> = std::result::Result<T, E>;
124
125impl Error {
126    pub fn new(error: impl StdError + Send + Sync + 'static) -> Self {
127        Self(Box::new(error))
128    }
129
130    pub fn msg(message: impl Into<String>) -> Self {
131        Self::new(MietteError::new(message))
132    }
133
134    pub fn downcast_ref<T: StdError + 'static>(&self) -> Option<&T> {
135        self.0.downcast_ref()
136    }
137}
138
139impl Display for Error {
140    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
141        Display::fmt(&self.0, f)
142    }
143}
144
145impl Debug for Error {
146    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
147        let mut rendered = if let Some(error) = self.downcast_ref::<crate::error::UsageErr>() {
148            error.render()
149        } else {
150            self.to_string()
151        };
152        let mut source = self.0.source();
153        if source.is_some() {
154            rendered.push_str("\n\nCaused by:");
155        }
156        let mut index = 1;
157        while let Some(cause) = source {
158            rendered.push_str(&format!("\n  {index}: {cause}"));
159            source = cause.source();
160            index += 1;
161        }
162        f.write_str(&rendered)
163    }
164}
165
166impl StdError for Error {
167    fn source(&self) -> Option<&(dyn StdError + 'static)> {
168        self.0.source()
169    }
170}
171
172impl From<crate::error::UsageErr> for Error {
173    fn from(value: crate::error::UsageErr) -> Self {
174        Self::new(value)
175    }
176}
177
178pub trait IntoDiagnostic<T> {
179    fn into_diagnostic(self) -> Result<T>;
180}
181
182impl<T, E> IntoDiagnostic<T> for std::result::Result<T, E>
183where
184    E: StdError + Send + Sync + 'static,
185{
186    fn into_diagnostic(self) -> Result<T> {
187        self.map_err(Error::new)
188    }
189}
190
191/// Render one labeled source span in the compact style used for spec diagnostics.
192pub(crate) fn render_source(
193    title: &str,
194    source_name: &str,
195    source: &str,
196    span: SourceSpan,
197    label: &str,
198    help: Option<&str>,
199) -> String {
200    let offset = source.floor_char_boundary(span.offset().min(source.len()));
201    let lines = line_ranges(source);
202    let line_index = lines
203        .iter()
204        .rposition(|(start, _)| *start <= offset)
205        .unwrap_or(0);
206    let (line_start, line_end) = lines[line_index];
207    let line = expand_tabs(&source[line_start..line_end], 4);
208    let line_no = line_index + 1;
209    let column = display_width(&source[line_start..offset], 0, 4);
210    let requested_end = offset.saturating_add(span.len()).min(line_end);
211    let span_end = source.ceil_char_boundary(requested_end).min(line_end);
212    let marked = display_width(&source[offset..span_end], column, 4).max(1);
213    let width = line_no.to_string().len();
214    let location = if source_name.is_empty() {
215        format!("[{}:{}]", line_no, column + 1)
216    } else {
217        format!("[{source_name}:{}:{}]", line_no, column + 1)
218    };
219    let mut out = format!(
220        "  × {title}\n {blank:width$} ╭─{location}\n {line_no:>width$} │ {line}\n {blank:width$} · {blank:column$}{mark:─<marked$}┬\n {blank:width$} · {blank:indent$}╰── {label}\n {blank:width$} ╰────",
221        blank = "",
222        mark = "",
223        width = width,
224        column = column,
225        marked = marked,
226        indent = column + marked,
227    );
228    if let Some(help) = help {
229        out.push_str("\n  help: ");
230        out.push_str(help);
231    }
232    out
233}
234
235fn line_ranges(source: &str) -> Vec<(usize, usize)> {
236    let mut ranges = Vec::new();
237    let mut start = 0;
238    let mut chars = source.char_indices().peekable();
239    while let Some((at, ch)) = chars.next() {
240        let is_newline = matches!(
241            ch,
242            '\r' | '\n' | '\u{0085}' | '\u{000B}' | '\u{000C}' | '\u{2028}' | '\u{2029}'
243        );
244        if !is_newline {
245            continue;
246        }
247        ranges.push((start, at));
248        if ch == '\r' && chars.peek().is_some_and(|(_, next)| *next == '\n') {
249            chars.next();
250        }
251        start = chars.peek().map_or(source.len(), |(at, _)| *at);
252    }
253    ranges.push((start, source.len()));
254    ranges
255}
256
257fn display_width(value: &str, starting_column: usize, tab_width: usize) -> usize {
258    let mut column = starting_column;
259    for ch in value.chars() {
260        if ch == '\t' {
261            column += tab_width - (column % tab_width);
262        } else {
263            column += ch.width().unwrap_or(0);
264        }
265    }
266    column - starting_column
267}
268
269fn expand_tabs(value: &str, tab_width: usize) -> String {
270    let mut out = String::with_capacity(value.len());
271    let mut column = 0;
272    for ch in value.chars() {
273        if ch == '\t' {
274            let spaces = tab_width - (column % tab_width);
275            out.extend(std::iter::repeat_n(' ', spaces));
276            column += spaces;
277        } else {
278            out.push(ch);
279            column += ch.width().unwrap_or(0);
280        }
281    }
282    out
283}
284
285#[macro_export]
286macro_rules! __usage_miette {
287    ($fmt:literal $(, $arg:expr)* $(,)?) => {
288        $crate::miette::Error::msg(format!($fmt $(, $arg)*))
289    };
290    ($err:expr $(,)?) => {
291        $crate::miette::Error::msg($err.to_string())
292    };
293}
294
295#[macro_export]
296macro_rules! __usage_bail {
297    ($($arg:tt)*) => {
298        return Err($crate::__usage_miette!($($arg)*))
299    };
300}
301
302pub use crate::__usage_bail as bail;
303pub use crate::__usage_miette as miette;
304
305#[cfg(test)]
306mod tests {
307    use super::{render_source, Error, MietteError, SourceSpan};
308    use crate::error::UsageErr;
309    use crate::Spec;
310    use std::path::Path;
311
312    #[test]
313    fn a_kdl_syntax_error_keeps_its_source_label_and_help() {
314        let source = "name broken\nflag --output {\n  arg \"unterminated\n}\n";
315        let error = source.parse::<Spec>().unwrap_err();
316        let rendered = format!("{:?}", Error::from(error));
317
318        assert!(
319            rendered.contains("Unexpected newline in single-line quoted string"),
320            "{rendered}"
321        );
322        assert!(rendered.contains("3 │   arg \"unterminated"), "{rendered}");
323        assert!(rendered.contains("╰── not quoted string"), "{rendered}");
324        assert!(
325            rendered.contains("help: You can make a string multi-line"),
326            "{rendered}"
327        );
328    }
329
330    #[test]
331    fn source_renderer_aligns_wide_gutters_and_utf8_spans() {
332        let source = format!("{}éx", "x\n".repeat(9));
333        let offset = source.find('é').unwrap();
334        let rendered = render_source(
335            "bad value",
336            "",
337            &source,
338            SourceSpan::from(offset..offset + 'é'.len_utf8()),
339            "invalid",
340            None,
341        );
342
343        assert!(rendered.contains(" 10 │ éx\n    · ─┬"), "{rendered}");
344
345        // Robustness for spans supplied by callers rather than the parser: a byte offset in the
346        // middle of a code point is normalized instead of panicking.
347        let rendered = render_source(
348            "bad value",
349            "",
350            &source,
351            SourceSpan::new((offset + 1).into(), 1),
352            "invalid",
353            None,
354        );
355        assert!(rendered.contains(" 10 │ éx\n    · ─┬"), "{rendered}");
356    }
357
358    #[test]
359    fn source_renderer_handles_every_kdl_newline() {
360        for newline in [
361            "\r\n", "\r", "\n", "\u{0085}", "\u{000B}", "\u{000C}", "\u{2028}", "\u{2029}",
362        ] {
363            let source = format!("first{newline}bad");
364            let offset = source.find("bad").unwrap();
365            let rendered = render_source(
366                "bad value",
367                "spec.kdl",
368                &source,
369                SourceSpan::from(offset..offset + 3),
370                "invalid",
371                None,
372            );
373
374            assert!(
375                rendered.contains("[spec.kdl:2:1]"),
376                "{newline:?}: {rendered:?}"
377            );
378            assert!(rendered.contains("2 │ bad"), "{newline:?}: {rendered:?}");
379        }
380    }
381
382    #[test]
383    fn source_renderer_aligns_tabs_and_wide_characters() {
384        let source = "\t界bad";
385        let offset = source.find("bad").unwrap();
386        let rendered = render_source(
387            "bad value",
388            "",
389            source,
390            SourceSpan::from(offset..offset + 3),
391            "invalid",
392            None,
393        );
394
395        assert!(rendered.contains("1 │     界bad"), "{rendered}");
396        assert!(rendered.contains("  ·       ───┬"), "{rendered}");
397        assert!(rendered.contains("[1:7]"), "{rendered}");
398    }
399
400    #[test]
401    fn kdl_file_errors_include_the_filename_and_location() {
402        let directory = tempfile::tempdir().unwrap();
403        let path = directory.path().join("broken.usage.kdl");
404        std::fs::write(&path, "name \"ok\"\narg \"unterminated\n").unwrap();
405
406        let error = Spec::parse_file(Path::new(&path)).unwrap_err();
407        let rendered = format!("{:?}", Error::from(error));
408        assert!(
409            rendered.contains(&format!("[{}:2:5]", path.display())),
410            "{rendered}"
411        );
412    }
413
414    #[test]
415    fn reports_include_error_source_chains() {
416        #[derive(Debug, thiserror::Error)]
417        #[error("outer failure")]
418        struct Outer(#[source] MietteError);
419
420        let rendered = format!("{:?}", Error::new(Outer(MietteError::new("root cause"))));
421        assert!(
422            rendered.contains("outer failure\n\nCaused by:\n  1: root cause"),
423            "{rendered}"
424        );
425    }
426
427    #[test]
428    fn plain_errors_keep_their_underlying_message() {
429        let error = UsageErr::from(MietteError::new("specific detail"));
430        assert_eq!(error.to_string(), "Invalid usage config: specific detail");
431
432        let error = "name \"unterminated\n".parse::<Spec>().unwrap_err();
433        assert!(error.to_string().contains("Unexpected newline"), "{error}");
434    }
435
436    #[cfg(feature = "miette")]
437    #[test]
438    fn a_kdl_syntax_error_works_with_a_miette_reporter() {
439        let source = "name broken\nflag --output {\n  arg \"unterminated\n}\n";
440        let error = source.parse::<Spec>().unwrap_err();
441        let mut rendered = String::new();
442
443        ::miette::NarratableReportHandler::new()
444            .render_report(&mut rendered, &error)
445            .unwrap();
446
447        assert!(
448            rendered.contains("Unexpected newline in single-line quoted string"),
449            "{rendered}"
450        );
451        assert!(rendered.contains("line 3, columns 7 to 19"), "{rendered}");
452        assert!(rendered.contains("not quoted string"), "{rendered}");
453        assert!(
454            rendered.contains("You can make a string multi-line"),
455            "{rendered}"
456        );
457    }
458}